用于在REST Web服务中处理批处理操作的模式?


170

REST风格的Web服务中对资源进行批处理操作存在哪些经过验证的设计模式?

我正在努力在性能和稳定性方面实现理想与现实之间的平衡。现在,我们有了一个API,所有操作都可以从列表资源(即GET / user)或单个实例(PUT / user / 1,DELETE / user / 22等)中检索。

在某些情况下,您想更新整套对象的单个字段。来回发送每个对象的整个表示以更新一个字段似乎非常浪费。

在RPC样式API中,您可以使用以下方法:

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

REST相当于什么?还是可以不时做出妥协。它会破坏设计以添加一些确实可以提高性能的特定操作吗?在所有情况下,客户端现在都是Web浏览器(客户端上的javascript应用程序)。

Answers:


77

批处理的一种简单的RESTful模式是利用集合资源。例如,一次删除多条消息。

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

批量更新部分资源或资源属性要稍微复杂一些。也就是说,更新每个markedAsRead属性。基本上,不是将属性视为每个资源的一部分,而是将其视为放置资源的存储桶。一个例子已经发布。我做了一点调整。

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

基本上,您将更新标记为已读的邮件列表。

您也可以使用它来将多个项目分配给同一类别。

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

进行iTunes风格的批量部分更新(例如,artist + 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

RESTful的创造力比比皆是。


1
有人可能会说您的删除实际上应该是一个帖子,因为它实际上并没有破坏该资源。
克里斯·尼古拉

6
没必要 POST是一种工厂模式的方法,它不如PUT / DELETE / GET显式和明显。唯一的期望是服务器将根据POST决定要做什么。POST就是过去的样子,我提交表单数据,服务器完成了一些工作(希望如此),并给了我一些有关结果的指示。我们通常不需要选择使用POST创建资源。我可以使用PUT轻松创建资源,我只需要将资源URL定义为发送者(通常并不理想)。
克里斯·尼古拉

1
@nishant,在这种情况下,您可能不需要在URI中引用多个资源,而只需在请求主体中传递带有引用/值的元组。例如,POST / mail / markAsRead,正文: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
为此保留分号。
亚历克斯

1
令人惊讶的是,没有人指出可以很好地涵盖在单一资源上更新多个属性的情况PATCH-在这种情况下,无需创新。
LB2

25

一点都不-我认为REST等效项(或者至少是一种解决方案)几乎完全是这样-一种专门设计的接口可以适应客户端所需的操作。

我想起了Crane和Pascarello的《Ajax in Action》一书中提到的一种模式(顺便说一句,这是一本很好的书-强烈推荐),在该模式中,他们说明了实现CommandQueue类对象的工作,该对象的工作是将请求分批排队,然后然后定期将它们发布到服务器。

如果我没记错的话,该对象实际上只是持有一个“命令”数组-例如,为了扩展您的示例,每个对象都包含一个包含“ markAsRead”命令,“ messageId”以及可能是对回调/处理程序的引用的记录。功能-然后根据某些时间表或根据用户的某些操作,将命令对象序列化并发布到服务器,然后由客户端处理后续的后处理。

我碰巧没有方便的细节,但是听起来像这样的命令队列将是处理您的问题的一种方法。它可以显着减少总体聊天情况,并以一种您可能会发现的更灵活的方式抽象化服务器端接口。


更新:啊哈!我在网上找到了那本书的片段,并附有代码示例(尽管我仍然建议您拿起实际的书!)。 从5.5.3节开始看这里

这很容易编写代码,但是会导致大量非常小的服务器流量,效率低下并可能造成混乱。如果我们想控制流量,我们可以捕获这些更新并在本地排队 ,然后在闲暇时将它们批量发送到服务器。清单5.13显示了用JavaScript实现的简单更新队列。[...]

队列维护两个数组。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
        }
    }
}

那应该让你走。祝好运!


谢谢。这与我关于如果将批处理操作保留在客户端上时如何前进的想法非常相似。问题是对大量对象执行操作的往返时间。
Mark Renouf

嗯,好的-我想您想通过轻量级请求对大量对象(在服务器上)执行操作。我误会了吗
Christian Nunciato

是的,但是我看不出该代码示例如何更有效地执行该操作。它分批处理请求,但仍然一次将它们发送到服务器。我在误解吗?
Mark Renouf

实际上,它将它们分批处理,然后一次将其全部发送:fireRequest()中的for循环本质上收集了所有未完成的命令,将它们序列化为字符串(带有.toRequestString(),例如,“ method = markAsRead&messageIds = 1,2,3 ,4“),将该字符串分配给” data“,然后将数据POST到服务器。
Christian Nunciato

20

虽然我认为@Alex是正确的道路,但从概念上讲,我认为应该与所建议的相反。

该URL实际上是“我们定位的资源”,因此:

    [GET] mail/1

表示从ID为1的邮件中获取记录,

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

表示修补ID为1的邮件记录。querystring是一个“过滤器”,用于过滤从URL返回的数据。

    [GET] mail?markAsRead=true

因此,在这里,我们要求所有已标记为已读的邮件。因此,要[PATCH]到此路径将是说“修补标记为true 的记录”……这不是我们要实现的目标。

因此,遵循此思路的批处理方法应为:

    [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 Request URI too long”错误。
rinogo '16

@rinogo-实际上不。这就是我的意思。querystring是我们要处理的记录的过滤器(例如[GET] mail / 1获取ID为1的邮件记录,而[GET] mail?markasRead = true返回其中markAsRead已经为true的邮件)。实际上,当我们想要修补ID为1,2,3的特定记录时,不要修改该相同的URL(即,在markAsRead = true处修补记录),这与字段markAsRead的当前状态无关。因此,我描述的方法。同意更新许多记录存在问题。我会建立一个不太紧密耦合的端点。
fezfox

11

您的语言“这似乎很浪费……”,对我来说表明是过早优化的尝试。除非可以证明发送对象的完整表示形式会严重影响性能(对于大于150ms的用户来说,这是不可接受的),否则尝试创建新的非标准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是最有意义的,问题是,如果当这些属性更改时还需要运行其他状态转换代码,则实现为简单的PATCH会变得更加困难。我不认为REST实际上可以容纳任何类型的状态转换,因为假定它是无状态的,所以它不在乎它从/向哪个转换,仅在乎当前状态是什么。
BeniRose

嘿BeniRose,感谢您添加评论,我经常想知道人们是否看到其中一些帖子。我很高兴看到人们这样做。有关REST的“无状态”性质的资源将其定义为服务器不必在请求之间维护状态的问题。因此,我不清楚您要描述的是什么问题,您能否举例说明一下?
justin.hughey

8

Google Drive API有一个非常有趣的系统来解决此问题(请参阅此处)。

他们所做的基本上是将一个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概念“兼容”。
Mark Renouf

嗨,是的,我明白。优化的确使该概念更具RESTful风格,我不想仅仅因为它偏离主题很小而放弃了我的建议。

1

很棒的帖子。我一直在寻找解决方案几天。我想出了一种解决方案,该解决方案使用传递带有以逗号分隔的一堆ID的查询字符串,例如:

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

...然后将其传递给WHERE IN我的SQL中的子句。它很好用,但是想知道其他人对这种方法的看法。


1
我不太喜欢它,因为它引入了新的类型,即您在其中用作列表的字符串。我宁愿将其解析为特定于语言的类型,然后在其中使用相同的方法。在系统的多个不同部分中使用相同的方式。
softarn 2014年

4
提醒您注意SQL注入攻击,并在采用这种方法时始终清除数据并使用绑定参数。
justin.hughey 2014年

2
取决于DELETE /books/delete?id=1,2,33号书不存在时的预期行为- WHERE IN会默默地忽略记录,而DELETE /books/delete?id=3如果3号书不存在,我通常会期望404。
chbrown

3
使用此解决方案可能遇到的另一个问题是URL字符串中允许的字符数限制。如果有人决定批量删除5,000条记录,浏览器可能会拒绝该URL或HTTP Server(例如Apache)可能会拒绝它。一般规则(希望随着更好的服务器和软件而发生变化)最大容量为2KB。在POST正文中,您可以增加到10MB。 stackoverflow.com/questions/2364840/…–
justin.hughey

0

从我的角度来看,我认为Facebook的实施效果最好。

使用批处理参数发出一个HTTP请求,对于令牌发出一个HTTP请求。

批量发送json。其中包含“请求”的集合。每个请求都有一个方法属性(get / post / put / delete / etc等)和一个relative_url属性(端点的uri),此外,post和put方法还允许一个“ body”属性,用于更新字段已发送。

更多信息,请访问:Facebook批处理API

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.