Wzorce obsługi operacji wsadowych w usługach internetowych REST?


170

Jakie sprawdzone wzorce projektowe istnieją dla operacji wsadowych na zasobach w ramach usługi sieci Web w stylu REST?

Staram się znaleźć równowagę między ideałami a rzeczywistością pod względem wydajności i stabilności. Mamy teraz API, w którym wszystkie operacje pobierają z zasobu listy (tj: GET / user) lub na pojedynczej instancji (PUT / user / 1, DELETE / user / 22, itd.).

W niektórych przypadkach chcesz zaktualizować jedno pole z całego zestawu obiektów. Przesyłanie całej reprezentacji każdego obiektu tam iz powrotem w celu zaktualizowania jednego pola wydaje się bardzo marnotrawstwem.

W interfejsie API w stylu RPC możesz mieć metodę:

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

Jaki jest tutaj odpowiednik REST? A może od czasu do czasu można pójść na kompromis. Czy dodanie kilku konkretnych operacji, w których naprawdę poprawia wydajność, itp. Psuje projekt? Klientem we wszystkich przypadkach jest obecnie przeglądarka internetowa (aplikacja javascript po stronie klienta).

Odpowiedzi:


77

Prostym wzorcem RESTful dla partii jest użycie zasobu kolekcji. Na przykład, aby usunąć kilka wiadomości jednocześnie.

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

Trochę bardziej skomplikowane jest zbiorcze aktualizowanie częściowych zasobów lub atrybutów zasobów. Oznacza to, że zaktualizuj każdy atrybut selectedAsRead. Zasadniczo zamiast traktować atrybut jako część każdego zasobu, traktujesz go jako zasobnik, w którym można umieścić zasoby. Jeden przykład został już opublikowany. Poprawiłem to trochę.

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

Zasadniczo aktualizujesz listę wiadomości oznaczonych jako przeczytane.

Możesz również użyć tego do przypisania kilku elementów do tej samej kategorii.

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

Oczywiście dużo bardziej skomplikowane jest wykonywanie częściowych aktualizacji w stylu iTunes (np. Wykonawca + tytuł albumu, ale nie tytuł utworu). Analogia wiadra zaczyna się załamywać.

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

W dłuższej perspektywie znacznie łatwiej jest zaktualizować pojedynczy częściowy zasób lub atrybuty zasobów. Wystarczy skorzystać z zasobu podrzędnego.

POST /mail/0/markAsRead
POSTDATA: true

Alternatywnie możesz użyć sparametryzowanych zasobów. Jest to mniej powszechne we wzorcach REST, ale jest dozwolone w specyfikacjach URI i HTTP. Średnik dzieli poziomo powiązane parametry w zasobie.

Zaktualizuj kilka atrybutów, kilka zasobów:

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

Zaktualizuj kilka zasobów, tylko jeden atrybut:

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

Zaktualizuj kilka atrybutów, tylko jeden zasób:

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

RESTful kreatywności jest mnóstwo.


1
Można argumentować, że usunięcie powinno w rzeczywistości być postem, ponieważ w rzeczywistości nie niszczy tego zasobu.
Chris Nicola,

6
To nie jest konieczne. POST jest metodą wzorca fabrycznego, jest mniej wyraźna i oczywista niż PUT / DELETE / GET. Jedynym oczekiwaniem jest to, że serwer zdecyduje, co zrobić w wyniku testu POST. POST jest dokładnie tym, czym zawsze był, przesyłam dane z formularza, a serwer coś robi (mam nadzieję, że jest oczekiwany) i daje mi wskazówkę co do wyniku. Nie jesteśmy zobowiązani do tworzenia zasobów za pomocą POST, po prostu często to robimy. Mogę łatwo utworzyć zasób za pomocą PUT, po prostu muszę zdefiniować adres URL zasobu jako nadawcę (często nie jest to idealne).
Chris Nicola

1
@nishant, w tym przypadku prawdopodobnie nie trzeba odwoływać się do wielu zasobów w identyfikatorze URI, ale wystarczy przekazać krotki z odwołaniami / wartościami w treści żądania. np. 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
Alex

3
W tym celu zarezerwowany jest średnik.
Alex

1
Zaskoczony, że nikt nie zauważył, że aktualizacja kilku atrybutów w jednym zasobie jest ładnie objęta PATCH- w tym przypadku nie ma potrzeby kreatywności.
LB2

25

Wcale nie - myślę, że odpowiednik REST to (lub przynajmniej jedno rozwiązanie jest) prawie dokładnie tym - wyspecjalizowany interfejs zaprojektowany z myślą o operacji wymaganej przez klienta.

Przypomina mi się wzorzec wspomniany w książce Crane'a i Pascarello Ajax in Action (nawiasem mówiąc, znakomita książka - bardzo polecana), w której ilustrują one implementację obiektu typu CommandQueue, którego zadaniem jest kolejkowanie żądań w partie i następnie wysyłaj je okresowo na serwer.

Obiekt, o ile dobrze pamiętam, zawierał po prostu tablicę "poleceń" - np. Aby rozszerzyć twój przykład, każdy z nich zawierał polecenie "markAsRead", "messageId" i być może odwołanie do wywołania zwrotnego / handlera function - a następnie zgodnie z harmonogramem lub działaniem użytkownika obiekt polecenia byłby serializowany i wysyłany do serwera, a klient zajmowałby się następczym przetwarzaniem końcowym.

Nie mam pod ręką szczegółów, ale wygląda na to, że kolejka poleceń tego rodzaju byłaby jednym ze sposobów rozwiązania problemu; znacznie zmniejszyłoby to ogólną rozmowę i wyodrębniłoby interfejs po stronie serwera w sposób, który może okazać się bardziej elastyczny w przyszłości.


Aktualizacja : Aha! Znalazłem w Internecie wycinek z tej książki, wraz z próbkami kodu (chociaż nadal sugeruję zakup właściwej książki!). Zajrzyj tutaj , zaczynając od sekcji 5.5.3:

Jest to łatwe do zakodowania, ale może skutkować bardzo małym ruchem na serwerze, co jest nieefektywne i potencjalnie mylące. Jeśli chcemy kontrolować nasz ruch, możemy przechwytywać te aktualizacje i umieszczać je w kolejce lokalnie, a następnie wysyłać je partiami na serwer w wolnym czasie. Prosta kolejka aktualizacji zaimplementowana w JavaScript jest pokazana na liście 5.13. […]

Kolejka obsługuje dwie tablice. queued to tablica indeksowana numerycznie, do której dołączane są nowe aktualizacje. sent jest tablicą asocjacyjną zawierającą aktualizacje, które zostały wysłane do serwera, ale oczekują na odpowiedź.

Oto dwie istotne funkcje - jedna odpowiedzialna za dodawanie poleceń do queue ( addCommand), a druga za serializację i wysyłanie ich do serwera ( 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
        }
    }
}

To powinno cię skłonić do działania. Powodzenia!


Dzięki. Jest to bardzo podobne do moich pomysłów, jak postąpiłbym, gdybyśmy pozostawili operacje wsadowe na kliencie. Problemem jest czas podróży w obie strony do wykonania operacji na dużej liczbie obiektów.
Mark Renouf

Hm, ok - myślałem, że chcesz wykonać operację na dużej liczbie obiektów (na serwerze) za pomocą lekkiego żądania. Czy źle zrozumiałem?
Christian Nunciato,

Tak, ale nie widzę, jak ten przykładowy kod mógłby wykonać operację bardziej efektywnie. Grupuje żądania, ale nadal wysyła je do serwera pojedynczo. Czy źle interpretuję?
Mark Renouf

W rzeczywistości dzieli je na partie, a następnie wysyła je wszystkie naraz: pętla for w fireRequest () zasadniczo zbiera wszystkie zaległe polecenia, serializuje je jako ciąg (z .toRequestString (), np. "Method = markAsRead & messageIds = 1,2,3 , 4 "), przypisuje ten ciąg do" danych "i POST wysyła dane do serwera.
Christian Nunciato

20

Chociaż myślę, że @Alex jest na dobrej drodze, myślę, że koncepcyjnie powinno być odwrotnością tego, co jest sugerowane.

Adres URL to w efekcie „zasoby, na które kierujemy reklamy”, stąd:

    [GET] mail/1

oznacza pobranie rekordu z poczty o identyfikatorze 1 i

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

oznacza załatanie rekordu poczty o id 1. Zapytanie jest „filtrem” filtrującym dane zwracane z adresu URL.

    [GET] mail?markAsRead=true

Więc tutaj prosimy o wszystkie wiadomości już oznaczone jako przeczytane. Zatem [PATCH] na tej ścieżce oznaczałoby „załataj rekordy już oznaczone jako prawdziwe”… co nie jest tym, co próbujemy osiągnąć.

Tak więc metoda wsadowa, zgodnie z tym myśleniem, powinna być:

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

oczywiście nie mówię, że to jest prawdziwy REST (który nie pozwala na manipulowanie rekordami wsadowymi), raczej jest zgodny z logiką już istniejącą i używaną przez REST.


Ciekawa odpowiedź! W ostatnim przykładzie, czy nie byłoby to bardziej spójne z [GET]formatem do zrobienia [PATCH] mail?markAsRead=true data: [{"id": 1}, {"id": 2}, {"id": 3}](lub nawet po prostu data: {"ids": [1,2,3]})? Inną korzyścią płynącą z tego alternatywnego podejścia jest to, że nie wystąpią błędy „414 Żądania URI zbyt długie”, jeśli aktualizujesz setki / tysiące zasobów w kolekcji.
rinogo

@rinogo - właściwie nie. To jest punkt, o którym mówiłem. Querystring jest filtrem dla rekordów, na których chcemy działać (np. [GET] mail / 1 pobiera rekord poczty o identyfikatorze 1, podczas gdy [GET] mail? MarkasRead = true zwraca mail, gdzie markAsRead ma już wartość true). Nie ma sensu łatać tego samego adresu URL (tj. "Łatać rekordy, gdzie markAsRead = true"), gdy w rzeczywistości chcemy załatać określone rekordy z identyfikatorami 1, 2, 3, NIEZALEŻNIE od aktualnego stanu pola markAsRead. Stąd metoda, którą opisałem. Zgadzam się, że jest problem z aktualizacją wielu rekordów. Zbudowałbym mniej ściśle powiązany punkt końcowy.
fezfox

11

Twój język, „ Wydaje się bardzo marnotrawny…”, wskazuje mi na próbę przedwczesnej optymalizacji. O ile nie można wykazać, że wysyłanie całej reprezentacji obiektów jest dużym spadkiem wydajności (mówimy o nie do przyjęcia dla użytkowników jako> 150 ms), nie ma sensu próbować tworzyć nowego niestandardowego zachowania API. Pamiętaj, im prostsze API, tym łatwiejsze w użyciu.

W przypadku usuwania wyślij następujące informacje, ponieważ serwer nie musi nic wiedzieć o stanie obiektu przed usunięciem.

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

Następna myśl jest taka, że ​​jeśli aplikacja ma problemy z wydajnością dotyczące zbiorczej aktualizacji obiektów, należy rozważyć podział każdego obiektu na wiele obiektów. W ten sposób ładunek JSON jest ułamkiem rozmiaru.

Na przykład podczas wysyłania odpowiedzi w celu zaktualizowania stanu „przeczytane” i „zarchiwizowane” dwóch oddzielnych wiadomości e-mail należy wysłać następujące informacje:

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"]
            }
            ]

Rozdzieliłbym zmienne składniki wiadomości e-mail (odczyt, zarchiwizowanie, ważność, etykiety) na osobny obiekt, ponieważ inne (do, od, temat, tekst) nigdy nie byłyby aktualizowane.

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"]}
          ]

Innym podejściem, które można zastosować, jest wykorzystanie PATCH. Wyraźne wskazanie, które właściwości zamierzasz zaktualizować, a wszystkie inne należy zignorować.

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

Ludzie twierdzą, że PATCH należy zaimplementować, dostarczając tablicę zmian zawierającą: akcję (CRUD), ścieżkę (URL) i zmianę wartości. Można to uznać za standardową implementację, ale jeśli spojrzeć na całość REST API, jest to nieintuicyjne rozwiązanie jednorazowe. Ponadto powyższa implementacja to sposób, w jaki GitHub zaimplementował PATCH .

Podsumowując, możliwe jest przestrzeganie zasad RESTful z akcjami wsadowymi i nadal mieć akceptowalną wydajność.


Zgadzam się, że PATCH ma największy sens, problem polega na tym, że jeśli masz inny kod przejścia stanu, który musi działać, gdy te właściwości się zmienią, trudniej będzie go zaimplementować jako prosty PATCH. Nie sądzę, aby REST naprawdę dostosował się do jakichkolwiek zmian stanu, biorąc pod uwagę, że ma być bezstanowy, nie obchodzi go, z czego i do czego przechodzi, tylko jaki jest jego aktualny stan.
BeniRose

Hej BeniRose, dzięki za dodanie komentarza, często zastanawiam się, czy ludzie widzą niektóre z tych postów. Cieszę się, że ludzie tak robią. Zasoby dotyczące „bezstanowego” charakteru REST definiują go jako problem związany z tym, że serwer nie musi utrzymywać stanu między żądaniami. W związku z tym nie jest dla mnie jasne, jaki problem opisywałeś, czy możesz podać przykład?
justin.hughey

8

Interfejs API dysku Google ma naprawdę ciekawy system rozwiązania tego problemu ( patrz tutaj ).

Zasadniczo grupują różne żądania w jednym Content-Type: multipart/mixedżądaniu, przy czym każde indywidualne pełne żądanie jest oddzielone określonym separatorem. Nagłówki i parametry zapytania w żądaniu wsadowym są dziedziczone do poszczególnych żądań (tj. Authorization: Bearer some_token), Chyba że zostaną nadpisane w indywidualnym żądaniu.


Przykład : (pobrane z ich dokumentów )

Żądanie:

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--

Odpowiedź:

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

W operacji takiej jak ta z twojego przykładu kusiłabym się, aby napisać parser zakresu.

Stworzenie parsera, który potrafi odczytać „MessageIds = 1-3,7-9,11,12-15” nie jest zbyt trudne. Z pewnością zwiększyłoby to wydajność ogólnych operacji obejmujących wszystkie wiadomości i jest bardziej skalowalne.


Dobra obserwacja i dobra optymalizacja, ale pytanie brzmiało, czy ten styl żądania mógłby kiedykolwiek być „zgodny” z koncepcją REST.
Mark Renouf

Cześć, tak rozumiem. Optymalizacja sprawia, że ​​koncepcja jest bardziej RESTful i nie chciałem pomijać mojej rady tylko dlatego, że odeszła trochę od tematu.

1

Wspaniały post. Szukałem rozwiązania od kilku dni. Wymyśliłem rozwiązanie polegające na przekazaniu ciągu zapytania z identyfikatorami grup oddzielonych przecinkami, na przykład:

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

... a następnie przekazanie tego do WHERE INklauzuli w moim SQL. Działa świetnie, ale zastanawiam się, co inni myślą o tym podejściu.


1
Nie podoba mi się to, ponieważ w pewnym sensie wprowadza nowy typ, ciąg, którego używasz jako listy, w której jest. Wolałbym raczej przeanalizować go do określonego typu języka, a następnie mogę użyć tej samej metody w w ten sam sposób w wielu różnych częściach systemu.
softarn

4
Przypomnienie, aby zachować ostrożność w przypadku ataków polegających na iniekcji SQL i zawsze czyścić dane i używać parametrów wiązania, stosując to podejście.
justin.hughey

2
Zależy od pożądanego zachowania, DELETE /books/delete?id=1,2,3gdy książka nr 3 nie istnieje - po WHERE INcichu zignoruje rekordy, podczas gdy zwykle spodziewałbym DELETE /books/delete?id=3się 404, jeśli 3 nie istnieje.
chbrown

3
Innym problemem, który możesz napotkać, korzystając z tego rozwiązania, jest ograniczenie liczby znaków w ciągu adresu URL. Jeśli ktoś zdecyduje się na zbiorcze usunięcie 5000 rekordów, przeglądarka może odrzucić adres URL lub serwer HTTP (na przykład Apache) może go odrzucić. Ogólna zasada (która, miejmy nadzieję, zmienia się wraz z lepszymi serwerami i oprogramowaniem) to maksymalny rozmiar 2 KB. Gdzie z treścią POST można przejść do 10 MB. stackoverflow.com/questions/2364840/…
justin.hughey Sierpnia

0

Z mojego punktu widzenia uważam, że Facebook ma najlepszą implementację.

Wysyłane jest pojedyncze żądanie HTTP z parametrem wsadowym i jednym dla tokenu.

W partii jest wysyłany plik json. który zawiera zbiór „wniosków”. Każde żądanie ma właściwość metody (get / post / put / delete / etc ...) irelative_url property (uri of endpoint), dodatkowo metody post i put pozwalają na właściwość "body", w której pola mają być aktualizowane są wysyłane .

więcej informacji na: Facebook batch API

Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.