Шаблони обробки пакетних операцій у веб-сервісах REST?


170

Які перевірені схеми дизайну існують для пакетних операцій над ресурсами в рамках веб-сервісу в стилі REST?

Я намагаюся досягти балансу між ідеалами та реальністю з точки зору продуктивності та стабільності. Зараз у нас є API, де всі операції отримують із списку ресурсів (тобто: GET / користувач) або в одному екземплярі (PUT / user / 1, DELETE / user / 22 тощо).

Є деякі випадки, коли ви хочете оновити одне поле цілого набору об’єктів. Здається дуже марно надсилати все представлення для кожного об'єкта вперед і назад для оновлення одного поля.

В API стилю RPC у вас може бути метод:

/mail.do?method=markAsRead&messageIds=1,2,3,4... etc. 

Який тут еквівалент REST? Або нормально йти на компроміси час від часу. Чи це зруйнує дизайн, щоб додати кілька конкретних операцій, де це дійсно покращує продуктивність тощо? Клієнтом у всіх випадках зараз є Веб-браузер (програма JavaScript на стороні клієнта).

Відповіді:


77

Простий шаблон RESTful для пакетів - це використання ресурсу збору. Наприклад, щоб видалити відразу кілька повідомлень.

DELETE /mail?&id=0&id=1&id=2

Дещо складніше пакетне оновлення часткових ресурсів або атрибутів ресурсу. Тобто оновіть кожен атрибут markAsRead. В основному, замість того, щоб атрибут розглядався як частина кожного ресурсу, ви розглядаєте його як відро, в яке потрібно розміщувати ресурси. Один приклад уже розміщено. Я його трохи коригував.

POST /mail?markAsRead=true
POSTDATA: ids=[0,1,2]

В основному ви оновлюєте список пошти, позначеної як прочитана.

Ви також можете використовувати це для віднесення декількох предметів до однієї категорії.

POST /mail?category=junk
POSTDATA: ids=[0,1,2]

Очевидно набагато складніше робити часткові оновлення в стилі iTunes (наприклад, виконавця + albumTitle, але не trackTitle). Аналогія відра починає руйнуватися.

POST /mail?markAsRead=true&category=junk
POSTDATA: ids=[0,1,2]

Зрештою, набагато простіше оновити окремий частковий ресурс або атрибути ресурсу. Просто скористайтеся підресурсом.

POST /mail/0/markAsRead
POSTDATA: true

Крім того, ви можете використовувати параметризовані ресурси. Це рідше в шаблонах REST, але дозволено в специфікаціях URI і HTTP. Точка з комою розділяє горизонтально пов'язані параметри всередині ресурсу.

Оновіть кілька атрибутів, кілька ресурсів:

POST /mail/0;1;2/markAsRead;category
POSTDATA: markAsRead=true,category=junk

Оновіть кілька ресурсів, лише один атрибут:

POST /mail/0;1;2/markAsRead
POSTDATA: true

Оновіть кілька атрибутів, лише один ресурс:

POST /mail/0/markAsRead;category
POSTDATA: markAsRead=true,category=junk

ТВОРЧА творчість рясніє.


1
Можна стверджувати, що ваше видалення насправді має бути публікацією, оскільки воно фактично не знищує цей ресурс.
Кріс Нікола

6
Це не потрібно. POST - метод фабричного шаблону, він менш явний і очевидний, ніж PUT / DELETE / GET. Єдине сподівання - сервер вирішить, що робити в результаті POST. POST - це саме те, що було завжди, я надсилаю дані форми, і сервер робить щось (сподіваюся, очікується) і дає мені певну інформацію про результат. Нам не потрібно створювати ресурси за допомогою POST, ми просто часто це робимо. Я легко можу створити ресурс за допомогою PUT, мені просто потрібно визначити URL-адресу ресурсу як відправника (не часто ідеального).
Кріс Нікола

1
@nishant, у цьому випадку вам, ймовірно, не потрібно посилатися на кілька ресурсів в URI, а лише передавати кортежі з посиланнями / значеннями в тілі запиту. наприклад, POST / mail / markAsRead, BODY: i_0_id = 0 & i_0_value = true & i_1_id = 1 & i_1_value = false & i_2_id = 2 & i_2_value = true
Алекс

3
крапка з комою зарезервована для цієї мети.
Олексій

1
Здивовано, що ніхто не вказував, що оновлення декількох атрибутів на одному ресурсі добре прикривається PATCH- у цьому випадку немає потреби в творчості.
LB2

25

Зовсім не - я думаю, що еквівалент REST - це (або, принаймні, одне рішення) майже саме це - спеціалізований інтерфейс, розроблений для розміщення операцій, необхідних клієнту.

Мені нагадали зразок, згаданий у книзі Крейн та Паскарелло « Ajax in Action» (відмінна книга, до речі, настійно рекомендується), в якій вони ілюструють реалізацію подібного об’єкта CommandQueue , завданням якого є встановлення черги на запити в партії та потім періодично публікуйте їх на сервері.

Якщо я добре пам’ятаю, об’єкт, по суті, просто містив масив «команд» - наприклад, для розширення вашого прикладу, кожен з яких містить запис, що містить команду «markAsRead», «messageId» і, можливо, посилання на зворотний виклик / обробник функція - і тоді згідно з деяким графіком або за деякими діями користувача, об'єкт команди буде серіалізований і розміщений на сервері, а клієнт обробляв би наступну післяобробну обробку.

У мене не трапляються деталі під рукою, але це здається, що черга команд такого роду була б одним із способів вирішити вашу проблему; це суттєво знизить загальну балаканину, а також абстрагує інтерфейс на стороні сервера таким чином, що ви можете виявити більш гнучким у дорозі.


Оновлення : Ага! Я знайшов фрагмент цієї книги в Інтернеті разом із зразками коду (хоча я все ж пропоную підібрати фактичну книгу!). Подивіться тут , починаючи з розділу 5.5.3:

Це легко кодувати, але може призвести до великої кількості дуже малих бітів трафіку до сервера, що є неефективним та потенційно заплутаним. Якщо ми хочемо контролювати наш трафік, ми можемо зафіксувати ці оновлення та встановити їх у черзі локально, а потім відправити їх на сервер партіями в наше дозвілля. Проста черга на оновлення, реалізована в JavaScript, показана в списку 5.13. [...]

Черга підтримує два масиви. queued - це числовий індексований масив, до якого додаються нові оновлення. sent це асоціативний масив, що містить ті оновлення, які були надіслані на сервер, але чекають відповіді.

Ось дві відповідні функції - одна відповідає за додавання команд у чергу ( addCommand), а одна відповідає за серіалізацію та потім їх відправлення на сервер ( fireRequest):

CommandQueue.prototype.addCommand = function(command)
{ 
    if (this.isCommand(command))
    {
        this.queue.append(command,true);
    }
}

CommandQueue.prototype.fireRequest = function()
{
    if (this.queued.length == 0)
    { 
        return; 
    }

    var data="data=";

    for (var i = 0; i < this.queued.length; i++)
    { 
        var cmd = this.queued[i]; 
        if (this.isCommand(cmd))
        {
            data += cmd.toRequestString(); 
            this.sent[cmd.id] = cmd;

            // ... and then send the contents of data in a POST request
        }
    }
}

Це повинно вас змусити йти. Удачі!


Дякую. Це дуже схоже на мої ідеї щодо того, як я б рухався вперед, якби ми зберігали пакетні операції на клієнті. Питання полягає в зворотному ході виконання операції на великій кількості об’єктів.
Марк Ренуф

Гм, нормально - я думав, що ви хочете виконати операцію над великою кількістю об'єктів (на сервері) за допомогою легкого запиту. Я неправильно зрозумів?
Крістіан Нунсіато

Так, але я не бачу, як цей зразок коду буде виконувати цю операцію ефективніше. Він збирає запити, але все одно надсилає їх на сервер по черзі. Я неправильно трактую?
Марк Ренуф

Насправді він збирає їх, а потім надсилає їх усі відразу: що для циклу у fireRequest () по суті збирає всі непогашені команди, серіалізує їх як рядок (з .toRequestString (), наприклад, "method = markAsRead & messageIds = 1,2,3 , 4 "), присвоює цьому рядку" data "та POSTs даним серверу.
Крістіан Нунсіато

20

Хоча я думаю, що @Alex йде по правильному шляху, концептуально я думаю, що він повинен бути зворотним для запропонованого.

URL по суті "ресурси, на які ми орієнтуємось", отже:

    [GET] mail/1

означає отримати запис з пошти з id 1 і

    [PATCH] mail/1 data: mail[markAsRead]=true

означає, виправити запис пошти за допомогою ідентифікатора 1. Рядок запитів - це "фільтр", фільтруючи дані, повернені з URL-адреси.

    [GET] mail?markAsRead=true

Тому тут ми просимо всю пошту, вже позначену як прочитану. Отже, щоб [PATCH] на цей шлях означав би "виправити записи, вже позначені як істинні" ... чого ми не прагнемо досягти.

Отже, пакетним методом слід дотримуватися цього мислення:

    [PATCH] mail/?id=1,2,3 <the records we are targeting> data: mail[markAsRead]=true

Звичайно, я не кажу, що це правда REST (що не дозволяє маніпулювати записом пакетних записів), скоріше це слід за логікою, яка вже існує та використовується REST.


Цікава відповідь! У вашому останньому прикладі, чи не було б це більше відповідати [GET]формату [PATCH] mail?markAsRead=true data: [{"id": 1}, {"id": 2}, {"id": 3}](або навіть просто data: {"ids": [1,2,3]})? Ще одна перевага цього альтернативного підходу полягає в тому, що ви не будете стикатися з помилками "414 Попросити URI занадто довго", якщо ви оновлюєте сотні / тисячі ресурсів у колекції.
rinogo

@rinogo - насправді ні. Це я і зробив. Рядок запитів є фільтром для записів, над якими ми хочемо діяти (наприклад, [GET] mail / 1 отримує запис пошти з ідентифікатором 1, тоді як [GET] пошта? MarkasRead = true повертає пошту, де markAsRead вже є істинним). Немає сенсу виправляти ту саму URL-адресу (тобто "виправляти записи, де markAsRead = істина"), коли насправді ми хочемо виправити певні записи з ід 1,2,3, ВІДПОВІДНО про поточний статус поля markAsRead. Звідси описаний я метод. Погодьтеся, існує проблема з оновленням багатьох записів. Я б створив менш щільно пов'язану кінцеву точку.
fezfox

11

Ваша мова "Це здається дуже марною ..." для мене вказує на спробу передчасної оптимізації. Якщо не може бути показано, що надсилання всього представлення об'єктів є головним ударом (ми говоримо неприйнятно для користувачів, як> 150 мс), тоді немає сенсу намагатися створити нову нестандартну поведінку API. Пам'ятайте, чим простіше API, тим простіше його використовувати.

Для делетів надсилайте наступне, оскільки серверу не потрібно нічого знати про стан об'єкта до того, як видалення відбудеться.

DELETE /emails
POSTDATA: [{id:1},{id:2}]

Наступна думка полягає в тому, що якщо програма стикається з проблемами продуктивності щодо масового оновлення об'єктів, то слід розглянути питання про розбиття кожного об'єкта на кілька об'єктів. Таким чином корисне навантаження JSON є часткою від розміру.

Як приклад при надсиланні відповіді на оновлення статусів "прочитати" та "заархівовано" двох окремих електронних листів, вам доведеться надіслати наступне:

PUT /emails
POSTDATA: [
            {
              id:1,
              to:"someone@bratwurst.com",
              from:"someguy@frommyville.com",
              subject:"Try this recipe!",
              text:"1LB Pork Sausage, 1 Onion, 1T Black Pepper, 1t Salt, 1t Mustard Powder",
              read:true,
              archived:true,
              importance:2,
              labels:["Someone","Mustard"]
            },
            {
              id:2,
              to:"someone@bratwurst.com",
              from:"someguy@frommyville.com",
              subject:"Try this recipe (With Fix)",
              text:"1LB Pork Sausage, 1 Onion, 1T Black Pepper, 1t Salt, 1T Mustard Powder, 1t Garlic Powder",
              read:true,
              archived:false,
              importance:1,
              labels:["Someone","Mustard"]
            }
            ]

Я б розділив змінні компоненти електронної пошти (читання, архівування, важливість, мітки) на окремий об'єкт, оскільки інші (до, з теми, тексту) ніколи не оновлюються.

PUT /email-statuses
POSTDATA: [
            {id:15,read:true,archived:true,importance:2,labels:["Someone","Mustard"]},
            {id:27,read:true,archived:false,importance:1,labels:["Someone","Mustard"]}
          ]

Ще один підхід - скористатися використанням PATCH. Чітко вказати, які властивості ви збираєтесь оновити, а всі інші слід ігнорувати.

PATCH /emails
POSTDATA: [
            {
              id:1,
              read:true,
              archived:true
            },
            {
              id:2,
              read:true,
              archived:false
            }
          ]

Люди заявляють, що PATCH слід реалізувати, надаючи масив змін, що містять: дію (CRUD), шлях (URL) та зміну значення. Це може вважатися стандартною реалізацією, але якщо дивитися на весь REST API, це неінтуїтивний одноразовий. Крім того, вищевказана реалізація - це те, як GitHub реалізував PATCH .

Підсумовуючи це, можна дотримуватися принципів RESTful за допомогою пакетних дій і все ще мати прийнятну продуктивність.


Я погоджуюсь, що PATCH має найбільше значення, проблема полягає в тому, що якщо у вас є інший код переходу стану, який потрібно запустити, коли ці властивості змінюються, реалізувати як простий ПАТЧ стає складніше. Я не думаю, що REST дійсно вміщує будь-який вид переходу держави, враховуючи, що він повинен бути без громадянства, йому не байдуже, що він переходить з та до, лише те, що це стан.
BeniRose

Ей, BeniRose, дякую, що додав коментар, мені часто цікаво, чи бачать люди деякі з цих публікацій. Мені це приємно бачити, що це роблять люди. Ресурси, що стосуються "безгромадянського" характеру REST, визначають це як занепокоєння тим, що серверу не потрібно підтримувати стан за запитами. Як таке, мені незрозуміло, яку проблему ви описували, чи можете ви розібратися з прикладом?
Justin.hughey

8

API диска google має дійсно цікаву систему для вирішення цієї проблеми ( див. Тут ).

Вони в основному групують різні запити в одному Content-Type: multipart/mixedзапиті, при цьому кожен окремий повний запит розділяється певним розмежувачем. Параметри заголовків і запитів пакетного запиту успадковуються окремим запитам (тобто Authorization: Bearer some_token), якщо вони не перекрито в індивідуальному запиті.


Приклад : (взяті з їхніх документів )

Запит:

POST https://www.googleapis.com/batch

Accept-Encoding: gzip
User-Agent: Google-HTTP-Java-Client/1.20.0 (gzip)
Content-Type: multipart/mixed; boundary=END_OF_PART
Content-Length: 963

--END_OF_PART
Content-Length: 337
Content-Type: application/http
content-id: 1
content-transfer-encoding: binary


POST https://www.googleapis.com/drive/v3/files/fileId/permissions?fields=id
Authorization: Bearer authorization_token
Content-Length: 70
Content-Type: application/json; charset=UTF-8


{
  "emailAddress":"example@appsrocks.com",
  "role":"writer",
  "type":"user"
}
--END_OF_PART
Content-Length: 353
Content-Type: application/http
content-id: 2
content-transfer-encoding: binary


POST https://www.googleapis.com/drive/v3/files/fileId/permissions?fields=id&sendNotificationEmail=false
Authorization: Bearer authorization_token
Content-Length: 58
Content-Type: application/json; charset=UTF-8


{
  "domain":"appsrocks.com",
   "role":"reader",
   "type":"domain"
}
--END_OF_PART--

Відповідь:

HTTP/1.1 200 OK
Alt-Svc: quic=":443"; p="1"; ma=604800
Server: GSE
Alternate-Protocol: 443:quic,p=1
X-Frame-Options: SAMEORIGIN
Content-Encoding: gzip
X-XSS-Protection: 1; mode=block
Content-Type: multipart/mixed; boundary=batch_6VIxXCQbJoQ_AATxy_GgFUk
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
Date: Fri, 13 Nov 2015 19:28:59 GMT
Cache-Control: private, max-age=0
Vary: X-Origin
Vary: Origin
Expires: Fri, 13 Nov 2015 19:28:59 GMT

--batch_6VIxXCQbJoQ_AATxy_GgFUk
Content-Type: application/http
Content-ID: response-1


HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Date: Fri, 13 Nov 2015 19:28:59 GMT
Expires: Fri, 13 Nov 2015 19:28:59 GMT
Cache-Control: private, max-age=0
Content-Length: 35


{
 "id": "12218244892818058021i"
}


--batch_6VIxXCQbJoQ_AATxy_GgFUk
Content-Type: application/http
Content-ID: response-2


HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Date: Fri, 13 Nov 2015 19:28:59 GMT
Expires: Fri, 13 Nov 2015 19:28:59 GMT
Cache-Control: private, max-age=0
Content-Length: 35


{
 "id": "04109509152946699072k"
}


--batch_6VIxXCQbJoQ_AATxy_GgFUk--

1

Я б спокусився в такій операції, як у вашому прикладі, щоб написати аналізатор діапазону.

Не зайвим буде зробити аналізатор, який може читати "messageIds = 1-3,7-9,11,12-15". Це, безумовно, збільшить ефективність для покривних операцій, що охоплюють усі повідомлення, і є більш масштабованою.


Гарне спостереження та хороша оптимізація, але питання полягало у тому, чи може цей стиль запиту колись бути "сумісним" із концепцією REST.
Марк Ренуф

Привіт, так, я розумію. Оптимізація робить концепцію більш ВІДКРИТИМ, і я не хотів залишати свої поради лише тому, що він мандрував невеликим шляхом від теми.

1

Чудовий пост. Я шукав рішення кілька днів. Я придумав рішення, як використовувати передачу рядка запиту з куповими ідентифікаторами, розділеними комами, як-от:

DELETE /my/uri/to/delete?id=1,2,3,4,5

... передаючи це до WHERE INпункту мого SQL. Це чудово працює, але дивуйтеся, що інші думають про цей підхід.


1
Мені це дуже не подобається, тому що він вводить новий тип - рядок, який ви використовуєте як список, де знаходиться. Я скоріше розбираю його на конкретному мовному типі, і тоді я можу використовувати той самий метод у однаково в декількох різних частинах системи.
softarn

4
Нагадування, щоб бути обережними при атаках SQL-ін’єкцій та завжди очищати свої дані та використовувати параметри прив'язки, використовуючи такий підхід.
justin.hughey

2
Залежить від бажаної поведінки, DELETE /books/delete?id=1,2,3коли книга №3 не існує - WHERE INволі мовчки ігноруватимуть записи, тоді як, як правило, я очікую DELETE /books/delete?id=3404, якщо 3 не буде.
chbrown

3
Інша проблема, з якою ви можете зіткнутися з використанням цього рішення, - це обмеження кількості символів, дозволених у рядку URL-адреси. Якщо хтось вирішить видалити 5000 записів, браузер може відхилити URL-адресу, а сервер HTTP (наприклад, Apache) може відхилити його. Загальне правило (яке, сподіваємось, змінюється з використанням кращих серверів та програмного забезпечення), - це максимальний розмір 2 КБ. З тілом POST ви можете піднятися до 10 Мб. stackoverflow.com/questions/2364840 / ...
justin.hughey

0

З моєї точки зору, я думаю, що у Facebook є найкраща реалізація.

Один HTTP-запит робиться з параметрами batch та одним для маркера.

У партії відправляється json. який містить колекцію "запитів". Кожен запит має властивість методу (get / post / put / delete / тощо ...), а також властивість Rela_url (uri кінцевої точки), додатково методи публікації та put дозволяють властивості "body", де поля оновлюються відправляються.

докладнішу інформацію на: Facebook batch API

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.