处理REST web服务中批处理操作的模式?

180

有哪些被证明在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 应用程序)。

8个回答

82

一种简单的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的批量部分更新(例如,仅更新艺术家+专辑标题但不更新曲目标题)会更加复杂。桶子的比喻开始失效。

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
有人可能会认为你的删除操作实际上应该是一个发布操作,因为它并没有真正销毁那个资源。 - Chris Nicola
6
不必要。POST是一种工厂模式的方法,比PUT/DELETE/GET不那么明确和显而易见。唯一的期望是服务器将决定在POST后做什么。POST就像它一直以来的作用一样,我提交表单数据,服务器会执行某些操作(希望如预期般),并给我一些结果指示。我们不需要使用POST创建资源,只是通常选择这样做。我可以轻松使用PUT创建资源,只需将资源URL定义为发送方(通常不理想)。 - Chris Nicola
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 - Alex
3
分号被保留作为这个目的。 - Alex
2
很惊讶没有人指出,在单个资源上更新多个属性可以很好地使用“PATCH”进行处理 - 在这种情况下不需要创造力。 - LB2
显示剩余8条评论

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 的邮件记录进行补丁。查询字符串是一个“过滤器”,用于过滤从 URL 返回的数据。

    [GET] mail?markAsRead=true

所以我们在这里请求所有已标记为已读的邮件。因此,对于这个路径的[PATCH]操作将意味着“修补已标记为true的记录”...而这不是我们想要实现的目标。

因此,遵循这种思路的批处理方法应该是:

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

当然,我并不是说这是真正的REST(因为它不允许批量记录操作),而是遵循已经存在并被REST使用的逻辑。


有趣的回答!对于您最后的例子,使用[PATCH] mail?markAsRead=true data: [{"id": 1}, {"id": 2}, {"id": 3}](甚至只是 data: {"ids": [1,2,3]})不是更符合 [GET] 格式的一致性吗?这种替代方法的另一个好处是,如果您正在更新集合中的数百/数千个资源,则不会遇到“414请求URI过长”错误。 - rinogo
@rinogo - 实际上不是这样的。这就是我想表达的意思。查询字符串是用来过滤我们要处理的记录的(例如,[GET] mail/1 获取 id 为 1 的邮件记录,而 [GET] mail?markasRead=true 返回已经标记为已读的邮件)。将补丁应用到同一 URL 上是没有意义的(即,“在标记为已读为 true 的记录上打补丁”),因为实际上我们想打补丁到特定的 id 为 1、2、3 的记录,而不考虑字段 markAsRead 的当前状态。因此,我描述的方法是正确的。同意更新多条记录存在问题。我会构建一个松散耦合的端点。 - fezfox
不幸的是,当你接近URL字符串最大长度时,这个方法会失效,因为资源ID通常是20多个字符的UID。例如,在大量记录上更新标志或状态是一个常见的需求。 - Half_Duplex

13

从我的理解来看,您所说的语言“似乎”过于浪费,这表明您试图进行过早的优化。除非可以证明发送整个对象表示会严重影响性能(我们正在谈论对用户不可接受的超过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是最合理的,但问题在于如果您有其他状态转换代码需要在这些属性更改时运行,那么实现一个简单的PATCH就变得更加困难。我认为REST并不真正适应任何类型的状态转换,因为它应该是无状态的,它不关心它正在从哪个状态转换到哪个状态,只关心它当前的状态是什么。 - BeniRose
嘿,BeniRose,感谢您添加评论,我经常想知道人们是否看到了这些帖子。看到人们确实看到了这些帖子让我很高兴。关于REST的“无状态”本质的资源定义为服务器不必在请求之间维护状态。因此,我不清楚您所描述的问题是什么,请举个例子详细说明一下? - justin.hughey
1
性能问题不仅仅源于有效载荷大小。考虑一个只包含业务对象外观的表示层。在这种情况下进行更新最终将需要获取完整的业务对象,或者一开始就传递完整的业务对象。 - Half_Duplex

10

谷歌云端硬盘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--

2

在我看来,Facebook的实现是最好的。

只需进行一次HTTP请求,其中包括一个批处理参数和一个令牌。

在批处理中,发送了一个包含“请求”集合的JSON。 每个请求都有一个方法属性(get / post / put / delete /等...)和一个relative_url属性(端点的URI),另外post和put方法允许"body"属性,将要更新的字段发送过去。

更多信息请查看:Facebook批处理API


1

在像您示例中的操作中,我会倾向于编写一个范围解析器。

制作一个可以读取“messageIds = 1-3、7-9、11、12-15”的解析器并不麻烦。这肯定会提高覆盖所有消息的总体操作的效率,并且更具可扩展性。


很好的观察和优化,但问题在于这种请求方式是否可以与REST概念“兼容”。 - Mark Renouf
嗯,我明白。优化确实使概念更符合RESTful,而且我不想因为偏离主题了一点就不提供建议。 - One Monkey

1
很棒的文章。我已经搜索了几天的解决方案。我想到了一个解决方案,使用通过逗号分隔的一堆ID传递查询字符串,例如:
DELETE /my/uri/to/delete?id=1,2,3,4,5

我将其传递给我的SQL中的WHERE IN子句,效果很好,但想知道其他人对这种方法的看法。


1
我不太喜欢它,因为它引入了一种新类型——在where in中使用的字符串列表。我更愿意将其解析为特定于语言的类型,然后可以在系统的多个不同部分中以相同的方式使用相同的方法。 - softarn
4
请注意 SQL 注入攻击,并始终在采用此方法时清理数据并使用绑定参数。 - justin.hughey
2
根据 DELETE /books/delete?id=1,2,3 当书籍 #3 不存在时的期望行为而定--使用 WHERE IN 将会无声地忽略记录,但通常我希望在 DELETE /books/delete?id=3 中如果3不存在,则返回404。 - chbrown
3
使用这个解决方案可能遇到的另一个问题是 URL 字符串允许的字符限制。如果有人决定批量删除 5,000 条记录,浏览器可能会拒绝 URL 或者 HTTP 服务器(例如 Apache)可能会拒绝它。通常的规则(希望随着更好的服务器和软件而改变)是选择最大尺寸为 2KB。而在 POST 请求正文中,您可以上传至多 10MB 的数据。参考:https://dev59.com/53E95IYBdhLWcg3wUMLW - justin.hughey
通常在REST API中,@chbrown,DELETE操作旨在是幂等的,因此您不应该返回404,而应该返回204或200。 - David Keaveny

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接