¿Patrones para manejar operaciones por lotes en los servicios web REST?

170

¿Qué patrones de diseño probados existen para las operaciones por lotes en recursos dentro de un servicio web de estilo REST?

Estoy tratando de lograr un equilibrio entre los ideales y la realidad en términos de rendimiento y estabilidad. Tenemos una API en este momento donde todas las operaciones se recuperan de un recurso de lista (es decir: GET / user) o en una sola instancia (PUT / user / 1, DELETE / user / 22, etc.).

Hay algunos casos en los que desea actualizar un solo campo de un conjunto completo de objetos. Parece un desperdicio enviar la representación completa de cada objeto de un lado a otro para actualizar el campo.

En una API de estilo RPC, podría tener un método:

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

¿Cuál es el equivalente REST aquí? ¿O está bien comprometerse de vez en cuando? ¿Arruina el diseño para agregar algunas operaciones específicas donde realmente mejora el rendimiento, etc.? El cliente en todos los casos en este momento es un navegador web (aplicación javascript en el lado del cliente).

Mark Renouf
fuente

Respuestas:

77

Un patrón RESTful simple para lotes es hacer uso de un recurso de colección. Por ejemplo, para eliminar varios mensajes a la vez.

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

Es un poco más complicado actualizar por lotes los recursos parciales o los atributos de los recursos. Es decir, actualice cada atributo marcado como leído. Básicamente, en lugar de tratar el atributo como parte de cada recurso, lo trata como un depósito en el que colocar los recursos. Ya se publicó un ejemplo. Lo ajusté un poco.

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

Básicamente, está actualizando la lista de correo marcado como leído.

También puede usar esto para asignar varios elementos a la misma categoría.

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

Obviamente, es mucho más complicado hacer actualizaciones parciales por lotes al estilo iTunes (por ejemplo, artista + albumTitle pero no trackTitle). La analogía del cubo comienza a romperse.

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

A la larga, es mucho más fácil actualizar un solo recurso parcial o atributos de recursos. Simplemente haga uso de un subrecurso.

POST /mail/0/markAsRead
POSTDATA: true

Alternativamente, podría usar recursos parametrizados. Esto es menos común en los patrones REST, pero está permitido en las especificaciones URI y HTTP. Un punto y coma divide parámetros relacionados horizontalmente dentro de un recurso.

Actualice varios atributos, varios recursos:

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

Actualice varios recursos, solo un atributo:

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

Actualice varios atributos, solo un recurso:

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

La creatividad RESTful abunda.

Alex
fuente
1
Uno podría argumentar que su eliminación en realidad debería ser una publicación, ya que en realidad no está destruyendo ese recurso.
Chris Nicola
66
No es necesario POST es un método de patrón de fábrica, es menos explícito y obvio que PUT / DELETE / GET. La única expectativa es que el servidor decidirá qué hacer como resultado de la POST. POST es exactamente lo que siempre fue, envío los datos del formulario y el servidor hace algo (con suerte esperado) y me da alguna indicación sobre el resultado. No estamos obligados a crear recursos con POST, simplemente elegimos hacerlo. Puedo crear fácilmente un recurso con PUT, solo tengo que definir la URL del recurso como el remitente (no suele ser ideal).
Chris Nicola
1
@nishant, en este caso, probablemente no necesite hacer referencia a múltiples recursos en el URI, sino simplemente pasar tuplas con las referencias / valores en el cuerpo de la solicitud. por ejemplo, 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
el punto y coma está reservado para este propósito.
Alex
1
Sorprendido de que nadie haya señalado que la actualización de varios atributos en un solo recurso está bien cubierta PATCH, no hay necesidad de creatividad en este caso.
LB2
25

En absoluto: creo que el equivalente REST es (o al menos una solución es) casi exactamente eso: una interfaz especializada diseñada para acomodar una operación requerida por el cliente.

Recuerdo un patrón mencionado en el libro de Crane y Pascarello Ajax in Action (un libro excelente, por cierto, muy recomendado) en el que ilustran la implementación de un tipo de objeto CommandQueue cuyo trabajo es poner en cola las solicitudes en lotes y luego publíquelos en el servidor periódicamente.

El objeto, si no recuerdo mal, esencialmente contenía una serie de "comandos", por ejemplo, para ampliar su ejemplo, cada uno un registro que contiene un comando "markAsRead", un "messageId" y tal vez una referencia a una devolución de llamada / controlador función - y luego de acuerdo con alguna programación, o con alguna acción del usuario, el objeto de comando se serializará y se publicará en el servidor, y el cliente manejará el consiguiente procesamiento posterior.

No tengo los detalles a mano, pero parece que una cola de comandos de este tipo sería una forma de manejar su problema; reduciría sustancialmente la conversación general y abstraería la interfaz del lado del servidor de una manera que podría encontrar más flexible en el futuro.


Actualización : ¡Ajá! Encontré un fragmento de ese mismo libro en línea, completo con ejemplos de código (¡aunque todavía sugiero recoger el libro real!). Eche un vistazo aquí , comenzando con la sección 5.5.3:

Esto es fácil de codificar, pero puede generar muchos bits muy pequeños de tráfico hacia el servidor, lo cual es ineficiente y potencialmente confuso. Si queremos controlar nuestro tráfico, podemos capturar estas actualizaciones y ponerlas en cola localmente y luego enviarlas al servidor en lotes a nuestro gusto. En la lista 5.13 se muestra una cola de actualización simple implementada en JavaScript. [...]

La cola mantiene dos matrices. queued es una matriz indexada numéricamente, a la que se agregan nuevas actualizaciones. sent es una matriz asociativa que contiene las actualizaciones que se han enviado al servidor pero que están esperando una respuesta.

Aquí hay dos funciones pertinentes: una responsable de agregar comandos a la cola ( addCommand) y otra responsable de serializar y luego enviarlos al servidor ( 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
        }
    }
}

Eso debería ponerte en marcha. ¡Buena suerte!

Christian Nunciato
fuente
Gracias. Eso es muy similar a mis ideas sobre cómo avanzaría si mantuvieramos las operaciones por lotes en el cliente. El problema es el tiempo de ida y vuelta para realizar una operación en una gran cantidad de objetos.
Mark Renouf
Hm, ok. Pensé que querías realizar la operación en una gran cantidad de objetos (en el servidor) a través de una solicitud ligera. ¿Entendí mal?
Christian Nunciato
Sí, pero no veo cómo esa muestra de código realizaría la operación de manera más eficiente. Agrupa las solicitudes pero aún las envía al servidor de una en una. ¿Estoy malinterpretando?
Mark Renouf
En realidad, los agrupa y luego los envía todos a la vez: que for loop in fireRequest () esencialmente reúne todos los comandos pendientes, los serializa como una cadena (con .toRequestString (), por ejemplo, "method = markAsRead & messageIds = 1,2,3 , 4 "), asigna esa cadena a" datos ", y envía datos al servidor.
Christian Nunciato
20

Si bien creo que @Alex está en el camino correcto, conceptualmente creo que debería ser al revés de lo que se sugiere.

La URL es en efecto "los recursos a los que apuntamos", por lo tanto:

    [GET] mail/1

significa obtener el registro del correo con id 1 y

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

significa parchear el registro de correo con la identificación 1. La cadena de consulta es un "filtro", que filtra los datos devueltos desde la URL.

    [GET] mail?markAsRead=true

Así que aquí estamos solicitando todo el correo ya marcado como leído. Entonces, [PATCH] a este camino sería decir "parchear los registros ya marcados como verdaderos" ... que no es lo que estamos tratando de lograr.

Entonces, un método por lotes, siguiendo este pensamiento, debería ser:

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

Por supuesto, no digo que esto sea REST verdadero (que no permite la manipulación de registros por lotes), sino que sigue la lógica ya existente y en uso por REST.

fezfox
fuente
Interesante respuesta! Para su último ejemplo, ¿no sería más coherente con el [GET]formato [PATCH] mail?markAsRead=true data: [{"id": 1}, {"id": 2}, {"id": 3}](o incluso simplemente data: {"ids": [1,2,3]})? Otro beneficio de este enfoque alternativo es que no se encontrará con errores de "414 Solicitar URI demasiado tiempo" si está actualizando cientos / miles de recursos en la colección.
rinogo
@ rinogo - en realidad no. Este es el punto que estaba haciendo. La cadena de consulta es un filtro para los registros sobre los que queremos actuar (por ejemplo, [GET] mail / 1 obtiene el registro de correo con una identificación de 1, mientras que [GET] mail? MarkasRead = true devuelve correo donde markAsRead ya es verdadero). No tiene sentido aplicar un parche a esa misma URL (es decir, "parchear los registros donde markAsRead = true") cuando, de hecho, queremos parchear registros particulares con ids 1,2,3, SIN IMPORTAR el estado actual del campo markAsRead. De ahí el método que describí. De acuerdo, hay un problema con la actualización de muchos registros. Construiría un punto final menos estrechamente acoplado.
fezfox
11

Su lenguaje, " Parece muy derrochador ...", para mí indica un intento de optimización prematura. A menos que se pueda demostrar que enviar la representación completa de los objetos es un gran impacto en el rendimiento (estamos hablando de que los usuarios son inaceptables> 150 ms), entonces no tiene sentido intentar crear un nuevo comportamiento API no estándar. Recuerde, cuanto más simple es la API, más fácil es usarla.

Para las eliminaciones, envíe lo siguiente ya que el servidor no necesita saber nada sobre el estado del objeto antes de que ocurra la eliminación.

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

El siguiente pensamiento es que si una aplicación se encuentra con problemas de rendimiento con respecto a la actualización masiva de objetos, se debe considerar dividir cada objeto en varios objetos. De esa forma, la carga útil de JSON es una fracción del tamaño.

Como ejemplo, al enviar una respuesta para actualizar los estados de "lectura" y "archivado" de dos correos electrónicos separados, deberá enviar lo siguiente:

PUT /emails
POSTDATA: [
            {
              id:1,
              to:"[email protected]",
              from:"[email protected]",
              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:"[email protected]",
              from:"[email protected]",
              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"]
            }
            ]

Separaría los componentes mutables del correo electrónico (leído, archivado, importancia, etiquetas) en un objeto separado, ya que los otros (para, desde, asunto, texto) nunca se actualizarían.

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

Otro enfoque a tomar es aprovechar el uso de un PATCH. Para indicar explícitamente qué propiedades tiene la intención de actualizar y que todos los demás deben ignorarse.

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

Las personas afirman que PATCH debe implementarse proporcionando una serie de cambios que contengan: acción (CRUD), ruta (URL) y cambio de valor. Esto puede considerarse una implementación estándar, pero si observa la totalidad de una API REST, es una aplicación no intuitiva. Además, la implementación anterior es cómo GitHub ha implementado PATCH .

En resumen, es posible adherirse a los principios RESTful con acciones por lotes y aún tener un rendimiento aceptable.

justin.hughey
fuente
Estoy de acuerdo en que PATCH tiene más sentido, el problema es que si tiene otro código de transición de estado que necesita ejecutarse cuando esas propiedades cambian, se vuelve más difícil de implementar como un PATCH simple. No creo que REST realmente se adapte a ningún tipo de transición de estado, dado que se supone que no tiene estado, no le importa de qué está haciendo la transición, solo cuál es su estado actual.
BeniRose
Hola BeniRose, gracias por agregar un comentario, a menudo me pregunto si la gente ve algunas de estas publicaciones. Me alegra ver que la gente lo hace. Los recursos relacionados con la naturaleza "sin estado" de REST lo definen como una preocupación con el servidor que no tiene que mantener el estado en todas las solicitudes. Como tal, no está claro para mí qué problema estaba describiendo, ¿puede explicarlo con un ejemplo?
justin.hughey
8

La API de Google Drive tiene un sistema realmente interesante para resolver este problema ( ver aquí ).

Lo que hacen es básicamente agrupar diferentes solicitudes en una Content-Type: multipart/mixedsolicitud, con cada solicitud completa individual separada por algún delimitador definido. Los encabezados y el parámetro de consulta de la solicitud por lotes se heredan de las solicitudes individuales (es decir Authorization: Bearer some_token) a menos que se anulen en la solicitud individual.


Ejemplo : (tomado de sus documentos )

Solicitud:

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":"[email protected]",
  "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--

Respuesta:

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--
Ayudantes
fuente
1

Me sentiría tentado en una operación como la de su ejemplo para escribir un analizador de rango.

No es mucho problema hacer un analizador que pueda leer "messageIds = 1-3,7-9,11,12-15". Sin duda, aumentaría la eficiencia para las operaciones generales que cubren todos los mensajes y es más escalable.


fuente
Buena observación y una buena optimización, pero la pregunta era si este estilo de solicitud podría ser "compatible" con el concepto REST.
Mark Renouf
Hola, si entiendo. La optimización hace que el concepto sea más RESTANTE y no quería dejar de lado mi consejo solo porque se alejaba un poco del tema.
1

Buena publicación. He estado buscando una solución por unos días. Se me ocurrió una solución mediante el uso de pasar una cadena de consulta con un grupo de ID separados por comas, como:

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

... luego pasar eso a una WHERE INcláusula en mi SQL. Funciona muy bien, pero me pregunto qué piensan los demás de este enfoque.

Roberto
fuente
1
Realmente no me gusta porque introduce un tipo nuevo, la cadena que usas como una lista en donde. Prefiero analizarlo a un tipo específico de idioma y luego puedo usar el mismo método en el de la misma manera en múltiples partes diferentes del sistema.
softarn
44
Un recordatorio para ser cauteloso con los ataques de inyección SQL y siempre limpiar sus datos y usar parámetros de enlace al adoptar este enfoque.
justin.hughey
2
Depende del comportamiento deseado de DELETE /books/delete?id=1,2,3cuándo el libro # 3 no existe: WHERE INignorará silenciosamente los registros, mientras que generalmente esperaría DELETE /books/delete?id=3404 si 3 no existe.
chbrown
3
Un problema diferente que puede encontrar al usar esta solución es el límite de caracteres permitido en una cadena de URL. Si alguien decide eliminar en masa 5.000 registros, el navegador puede rechazar la URL o el Servidor HTTP (Apache, por ejemplo) puede rechazarla. La regla general (que con suerte está cambiando con mejores servidores y software) ha sido ir con un tamaño máximo de 2 KB. Donde con el cuerpo de un POST puedes subir hasta 10MB. stackoverflow.com/questions/2364840/…
justin.hughey
0

Desde mi punto de vista, creo que Facebook tiene la mejor implementación.

Se realiza una única solicitud HTTP con un parámetro por lotes y uno para un token.

En lote se envía un json. que contiene una colección de "solicitudes". Cada solicitud tiene una propiedad de método (get / post / put / delete / etc ...) y una propiedad relative_url (uri del punto final), además, los métodos post y put permiten una propiedad "body" donde se actualizan los campos se envían .

Más información en: API por lotes de Facebook

Leonardo Jauregui
fuente