0%

事件溯源 (Event sourcing) 和 Command Query Responsibility Segregation (CQRS) 通常会一起被提到。虽然两者之间没有直接的关系,但我们会发现它们是相辅相成的。
本章节介绍了事件溯源中的一些关键概念,并介绍了一些与 CQRS 模式相关的知识点。本章节仅仅是一个简介,在第4章节中会有关于事件溯源和 CQRS 之间关系的深入介绍。
为了帮助我们理解 事件溯源,首先我们需要理解 事件 的基本特征:

  • 事件是发生在过去的。举个例子,’预约了演讲者’、’预定了座位’,’发放了现金’,他们都是是用过去式描述这些事件的。
  • 事件是不可变的。应为事件是发生在过去的,所以他不能被修改或者撤回。但是,后续事件可能会更改或抵消早期事件的影响。例如,’预约已取消’ 该事件更改了先前预约事件的影响结果。
  • 事件是单向消息。事件只有一个源头(发布者)发布事件,一个或多个接收者(订阅者)接收事件。
  • 通常,事件包括事件相关的其他信息。例如,’E23座位是由爱丽丝预订的’。
  • 在事件溯源的上下文中,事件应描述业务意图。例如,’E23座位是由爱丽丝预订的’描述了业务发生了什么,这比’在预订表中,ID为E23的行的名称字段更新为爱丽丝’表达了更多信息。

我们还将本章讨论事件与聚合的关系。有关 DDD 术语,聚合、聚合根和实体的说明,请参见 《CQRS in Context》。其中有两个特性与事件和事件溯源有关:

  • 聚合定义了相关实体组的一致性边界。因此,我们可以聚合相关事件,来通知相关方更新该实体(更新的一致的性)
  • 每个聚合都有唯一的 ID,因此,我们可以使用该 ID 来记录该聚合有哪些事件。

在本章的其余部分,我们将使用 聚合 一词来指代一组关联的对象,这些对象被视为一个单元,以进行数据修改。这并不意味着事件溯源与 DDD 方法直接相关;我们只是使用 DDD 中的术语来尝试保持本指南中我们语言的一致性。

什么是事件溯源

事件溯源是我们持久化应用状态的一种方式,他通过存储历史事件来记录应用当前的状态。例如,会议室管理系统需要跟踪已经被预定会议室的座位情况,目的为了检查当有座位预定时,是否有空余的座位。系统可以通过两种方式存储会议室以被预订座位的总数:

  • 系统可以直接存储会议室座位被预订的总数,并在当有人预订或取消时修改此数字。我们可以将预订数视为一个整数值,该整数值存储在表的特定列中,该表在系统中每个会议都有一条记录。
  • 系统存储每个会议室座位被预定或取消的事件,并且通过重放该会议室相关的事件来计算当前被预定座位的总数。

关系数据库 vs 事件溯源

关系数据库

上图的处理步骤:

  1. 流程管理器或 UI 发出命令,为 ID 为 157 的会议室保留两个席位。该命令由 SeatsAvailability 聚合处理程序处理。
  2. 如有必要,对象关系映射(ORM)层将数据填充到聚合实体中。 ORM 从数据存储的表,来查询会议室被预定座位的现有数量。
  3. 命令处理程序在聚合实体上调用业务方法进行保留座位。
  4. SeatsAvailability 聚合执行其领域逻辑,计算会议室被预订座位新的总数。
  5. ORM 将聚合实体中的信息更新持久化到数据库中

有关流程管理器的定义,请参见第6章,《关于 Sagas 的传奇》

上图提供了该过程的简化视图。实际上,由 ORM 层执行的映射逻辑是更复杂的。我们还需要考虑何时执行加载和保存操作,以平衡一致性、可靠性、可伸缩性和性能的需求。

事件溯源

使用事件溯源代替 ORM 层和关系数据库(RDBMS),执行步骤:
  1. 流程管理器或 UI 发出命令,为 ID 为 157 的会议室保留两个席位。该命令由 SeatsAvailability 聚合处理程序处理
  2. 通过查询 SeatsAvailability 聚合 ID 为 157 的所有事件来生成聚合实例。
  3. 命令处理程序在聚合实例上调用业务方法进行保留座位。
  4. SeatsAvailability 聚合执行其领域逻辑,计算会议室被预订座位新的总数。SeatsAvailability 将创建一个事件,用于记录被预定的两个座位。
  5. 系统将’预定了两个座位的’的事件追加到与事件存储中。

第二种方法更简单,因为它省去了 ORM 层,并用更简单的方法代替了数据库中的复杂关系模型。数据库仅需要支持通过聚合对象 ID 查询历史事件、附加新事件的功能。我们仍然需要考虑读写事件的性能和可伸缩性,可以通过对聚合对象进行快照达到性能优化。因为这样我们无需查询和重放全部的事件,只需要从快照之后获取事件并重放即可获取聚合对象的当前状态,并且无需在内存中维护聚合对象的缓存副本。
我们还必须确保有一种机制,能够可以通过查询历史事件来重建聚合对象状态。
通过第二种方法,我们可以获取到会议预订和取消的完整历史记录。因此,事件流是我们唯一事实的来源。我们无须直接保存聚合对象,因为我们可以通过重放事件,将系统恢复到任何时间点的聚合状态。
在某些领域,例如账务领域,事件溯源是一种自然的、公认的方法。账务系统存储每个交易的事件,系统始终可以恢复到系统的当前状态。事件溯源也可以在其他领域带来类似的好处。

为什么我们需要使用事件溯源

到目前为止,我们使用事件溯源的唯一原因,是因为它存储了领域中聚合相关的全部历史事件。在某些领域(例如账务),这是至关重要的功能,在该领域中,我们需要账务交易的完整记录跟踪,并且事件必须是不可变的。交易一旦发生,就不能删除或更改,尽管可以根据需要创建新的事件进行修改或撤消交易。

使用事件溯源的主要好处是自带的审核机制,它可以确保事务数据和审核数据的一致性,因为它们是相同的数据。通过事件重放,允许我们随时重建到对象的任何状态。—Paweł Wilkosz(客户咨询委员会)

下面描述了使用事件溯源可以带来的一些其他好处:

  • 性能。由于事件是不可变的,因此在保存事件时仅有追加操作。事件也是简单的独立对象,与使用复杂的关系存储模型的方法相比,这两个点都可以为系统带来更好的性能和可伸缩性。
  • 简单。事件是简单的对象,它们描述系统中发生的事情。通过保存事件,可以避免将复杂领对象保存到关系存储所带来的复杂性。
  • 审计跟踪。事件是不可变的,并且还保存了所有事件的历史记录。这样,他们可以根据历史记录进行审计跟踪。
  • 与其他子系统的集成。事件提供了与其他子系统通信的方式。我们可以将事件通知给其他关心此事件的子系统。
  • 从历史事件中获取额外的业务价值。通过存储事件,我们可以通过查询与该时间点之前与领域对象关联的事件来还原任何时间点的系统状态,这样我们能够获取到系统所有的历史信息。此外,我们无法预测未来需要从系统中提取哪些新的信息。如果我们保存了事件,则不会丢弃将来可能是被认为有价值的信息。
  • 生产故障排除。我们可以通过复制生产事件存储并在测试环境中进行重放,来对生产系统中的问题进行故障排除。如果我们知道生产系统中发生问题的时间,那么我们可以轻松地重放事件流直至该点,即可准确分析产生的问题。
  • 修正错误。我们可能会发现代码的 bug,导致系统计算出错误的数值。我们可以修改代码的 bug 后,并重放事件流,达到系统根据正确的代码计算出正确的值。而不是修改代码后,并对存储的数据执行危险的手动调整。
  • 测试。聚合中的所有状态更改都记录为事件。因此,我们可以通过检查事件来判定结果是否符合预期。
  • 灵活性。事件序列可以转化为任何所需的结构存储。

    只要有事件流,就可以将其转化为任何形式存储,甚至是常规的 SQL 数据库。例如,我最喜欢将事件流存储在云存储中的 JSON 文档中。— Rinat Abdullin(Why Event Sourcing?

在第4章 《CQRS 和 ES 的深入探究》 将详细讨论这些好处。

事件溯源需要关注的问题

在上节中描述了使用事件溯源模式的一些好处。但是,我们可能面临一些问题需要解决:

  • 性能。虽然事件溯源确实提高了更新操作的性能,我们需要考虑,查询所有相关事件并重放所花费的时间。使用快照可以限制我们加载事件的数量,因为我们可以获取最新快照,然后从该点开始重放事件。可以查阅《CQRS 和 ES 的深入探究》获取更多信息。
  • 版本控制。我们可能在将来需要更改事件消息的结构。我们必须考虑系统如何处理修改事件结构导致的多版本问题。
  • 查询。虽然很容易通过重放事件的方式加载领域对象当前的状态,但是它对于执行条件查询来说是困难的。例如,查询价格超过 $250 的订单。如果我们实现了 CQRS 模式,我们应该记住,此类查询通常将在读取端执行,我们需要构建专门数据投影来执行此类查询。

CQRS/ES

CQRS 模式和事件溯源经常结合使用,他们互为补充。

第2章 《CQRS 介绍》 建议将事件从写入侧到读取侧进行推送同步。读取侧的数据存储通常包含非规范化数据,这些数据针对数据条件查询进行了优化。例如,在应用程序的 UI 中显示查询结果。

ES 是一种很好的模式,可用于实现写入和查询之间的联系。ES不是唯一的方法,但是一种合理的方法,还原事实的关键,来源于事件日志是临时的还是永久的。CQRS 模式本身要求在写入和读取之间进行区分,因此和 ES 完全是互补的。 - Clemens Vasters(CQRS顾问邮件列表)

事件溯源中领域模型的状态是事件流的持久化,而不是单个快照持久化,也不是关于如何让命令侧和查询侧如何保持数据同步的方法(通常使用基于发布/订阅消息的方法)。 - Udi Dahan(CQRS顾问邮件列表)

我们可以将写入端接收到的事件同步转发到读取端,读取端处理事件保存到物化试图中(View DB),来提供条件查询。

请注意,写入端将事件持久化到事件存储后再发布事件,这样可以避免使用两阶段提交。如果聚合负责将事件保存到事件存储中并将事件发布,则需要使用两阶段提交。
通常,这些事件使您可以实时地实时更新读取数据。事件传输机制可能会导致一些延迟,在第4章 《CQRS 和 ES 的深入探究》 讨论了这种延迟的可能带来的问题。
我们可以随时通过重放事件来重建数据。如果读取侧数据存储由于某种原因不同步,或者因为您需要修改读取侧数据存储的结构以支持新查询,则可能需要执行此操作。
如果其他领域的有界上下文也订阅了相同的事件,则需要小心地重放事件。因为在重放事件之前,清空读取侧存储的数据很容易,但是确保另一个领域的有界上下文的一致性可能不是那么容易。

事件存储

我们使用事件溯源,我们需要用一种机制方法,能够保存我们的事件,并且能够查询返回出事件流,用于通过重放事件流重新创建聚合实例的状态。这个存储机制通常称为事件存储。
我们可以实现自己的事件存储,或者使用第三方事件存储。例如,Jonathan Oliver 的 EventStore。 虽然我们实现一个小型事件存储相对容易,但是具备可靠性、可伸缩的将带来挑战性。
第8章,《总结:经验教训》 总结了我们团队实现自己的事件存储的经验。

基本要求

通常,当我们实现 CQRS 模式时,聚合会创建事件,将信息发给其他相关方。使用事件溯源时,将这些相同事件保留到事件存储中,让我们能够通过重放与该聚合关联的事件流来恢复聚合的状态。实际上,并非系统中的所有事件都必须具有订阅者。我们可以创建某些事件,仅是为了保留聚合的某些属性。

底层存储

事件不是复杂的数据结构。通常,会包含一些基础数据,如与之关联的聚合实例的 ID 、事件版本号,以及事件本身的详细信息。我们不需要使用关系数据库来存储事件,我们可以使用 NoSQL、文档数据库或文件系统存储。

性能、扩展性、持久化

存储的事件应该是不可变的,并且始终能够按其保存的顺序进行读取。因此保存事件应该是在底层存储上,执行简单、快速的追加操作。
加载持久化的事件时,必须按照它们最初保存的顺序来加载它们。如果使用关系数据库,则应使用聚合 ID 和定义的事件顺序的字段来加载。
如果聚合实例具有大量事件,这可能会影响重放所有事件来重新恢复聚合状态所花费的时间。在这种情况下,需要考虑的使用快照机制。除了事件存储中的完整事件流之外,我们还可以在最近的某个时间点存储聚合状态的快照。当要重新加载聚合的状态时,首先要加载最新的快照,然后重放快照之后的所有事件。我们可以在写入事件的过程中生成快照,例如,每处理 100 个事件创建一个聚合的快照。
作为替代方案,我们可以在内存中缓存使用率很高的聚合实例,避免反复重放事件流。
当事件存储保留事件时,它还必须发布该事件,即对事件消息的先保存后处理。为了保持系统的一致性,两个操作必须同时成功或失败。我们可以使用分布式两阶段提交事务,该事务将存储数据和消息发布包装在一起。但是实际上,我们会发现在许多数据存储和消息中间件,对两阶段提交事务提交的支持是有限制的。使用两阶段提交可能会限制系统的性能和可伸缩性。
如果选择使用自己实现的事件存储,则必须解决的关键问题之一就是如何实现一致性。
如果计划使用跨多个存储节点的分布式事件存储。在这种情况下,我们必须保证在分布式下,写数据的完全一致性,而不是最终一致性。
有关 CAP 定理和在分布式系统中保持一致性的更多信息,请参见下一章《CQRS 和 ES 的深入探究》

补充阅读

《事件溯源模式》

参考原文

《Reference 3: Introducing Event Sourcing》

REST 架构风格具有定义明确的约束,可帮助开发者写出可扩展的 Web 服务接口, 但是 APIs 并不容易定义。这就是为什么我归纳了这些问题和解决方案。

  1. 资源和基本操作
  2. 列表和分页
  3. 多对多关系
  4. 字段过滤
  5. 长时间运行的操作
  6. 并发处理
  7. 版本控制
  8. 资源整合聚合
  9. 多语言

资源和基本操作

REST API 完全是与资源相关的交互操作。客户端可以通过 API 创建、替换、更新、删除或者获取资源。所有的这些操作都和 HTTP 动作有明确的映射。
看如下的例子,给定 user 资源,客户端可以通过以下端点与之交互

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 创建一个资源
POST /users

// 通过资源的唯一标识,获取资源的详细信息
GET /users/:id

// 部分更新给定 ID 标识资源的内容
PATCH /users/:Id

// 替换(全量更新)给定 ID 标识资源的内容
PUT /users/:id

// 删除给定 ID 标识的资源
DELETE /users/:id

创建一个新用户

1
2
3
4
5
6
7
POST /users HTTP/1.1
Host: example.com
Content-Type: application/json
{
"name": "bob",
"age": 76
}

根据资源 ID 标识获取用户信息

1
2
GET /users/8646291 HTTP/1.1
Host: example.com

部分更新用户信息:

1
2
3
4
5
6
PATCH /users/8646291 HTTP/1.1
Host: example.com
Content-Type: application/json
{
"name": "bob-update"
}

PATCH 请求是对资源的部分更新,所以上述的例子,属性 age 将保持原来的值,仅仅名称发生了变化。
如果不选择使用 PATCH ,可以使用 POST 。但是不要用 PUT, HTTP 定义 PUT 用于全量更新或替换资源[RFC7231]。

1
2
3
4
5
6
PUT /users/8646291 HTTP/1.1
Host: example.com
Content-Type: application/json
{
"age": 54
}

如果使用如上请求更新用户的age,用户的 name 信息将丢失,因为 PUT 请求是全量更新的。

删除资源:

1
2
DELETE /users/8646291 HTTP/1.1
Host: example.com

列表和分页

客户端能够使用 GET 请求和顾虑条件获取大量数据。必须对结果进行分页,最常用的分页是基于游标的分页和基于偏移量的分页。每个分页请求都有限制参数,用于限制分页大小。

基于游标的分页

也许你也会看到它叫基于键集的分页,这是大的数据集分页中最有效的方法,因为性能要比基于偏移量的分页更好。
当客户端请求一个数据集时,服务端在响应中返回各个元素,并在每个元素中提供一个游标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# REQUEST
GET /users?limit=100 HTTP/1.1
Host: example.com
# RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"items": [
{
"id": 123,
"meta":{
"cursor": "js3Hsji3nj"
}
},
// other items ...
{
"id": 426,
"meta":{
"cursor": "ke3Gdk1xyi"
}
}
]
}

如你所看到的,每个元素都有一个 meta.cursor 属性,该游标是一个随机的字符串,用于标记元素列表中的特定元素,可用于检索下一个或上一个元素,并将其作为 afterbefore 参数。
如果 after 存在,则返回的该游标作为第一个元素之后的元素。如果该游标之后没有元素,则返回的集合必须为空。如果存在 before,则返回该游标作为最后一个元素之前的元素。客户端可以提交如下请求用来检索之前、之后的元素

1
2
3
4
5
6
7
8
9
# 获取之前的元素
GET /users?after=ke3Gdk1xyi&limit=100 HTTP/1.1
Host: example.com
# 获取之后的元素
GET /users?before=js3Hsji3nj&limit=100 HTTP/1.1
Host: example.com
# 获取之前的元素
GET /users?after=ke3Gdk1xyi&before=js3Hsji3nj&limit=100 HTTP/1.1
Host: example.com

基于偏移量的分页

基于偏移的分页,允许客户端跳转到特定页面,但是在大多数情况下,在处理非常大的数据集时性能会较差,但是它的知名度更高。
客户端提交如下的请求获取数据集。

1
2
3
4
5
6
7
8
9
10
11
12
# REQUEST
GET /users?limit=100 HTTP/1.1
Host: example.com

# RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"items": [
// the results
]
}

获取下一个分页,必须增加 skip 查询参数

1
2
3
4
5
6
// 获取第二页
GET /users?skip=100&limit=100 HTTP/1.1
Host: example.com
// 获取第三页
GET /users?skip=200&limit=100 HTTP/1.1
Host: example.com

页面引用

我们可以使用页面引用来简化分页操作,它提供指向页面的指针,也就是说标记特点页面的游标。
页面链接或页面游标通常会对页面位置进行编码(加密),即第一页或最后一页元素的标识符、分页方向和查询条件,用来安全地查询出数据集。
让我看如下的示例,客户端提交请求查询第一页。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# REQUEST
GET /users?limit=100 HTTP/1.1
Host: example.com
# RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"items": [
// the results
],
"paging": {
"prev": "kewIJbwDS2Bsja...",
"next": "dFRdkdek2KLmcd..."
}
}

补充说明一下,GitHub 的分页 API,分页返回的结果是在 Header 中的 Link 标签中。

1
2
3
4
5
6
7
8
9
10
11
12
13
# REQUEST
GET https://api.github.com/organizations/317776/repos?type=all&size=2&page=4
Host: api.github.com
# RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Link: <https://api.github.com/organizations/317776/repos?type=all&size=2&page=3>; rel="prev", <https://api.github.com/organizations/317776/repos?type=all&size=2&page=5>; rel="next", <https://api.github.com/organizations/317776/repos?type=all&size=2&page=7>; rel="last", <https://api.github.com/organizations/317776/repos?type=all&size=2&page=1>; rel="first"

{
"items": [
// the results
]
}

page.next 是客户端用于请求下一批数据集的页面引用, pageing.prev 是客户端用于请求上一批数据集的页面引用:

1
2
GET /users?page_ref=dFRdkdek2KLmcd&limit=100 HTTP/1.1
Host: example.com

在第一个请求之后,limit 参数是 URL 中除 page_ref 的唯一参数,因为这样可以保护两次请求之间的限制条件被篡改。防止破坏请求参数(例如排序和筛选条件)直接嵌入到 page_ref 中以某种方式存储。尝试添加或修改过滤条件将导致请求失败。如果需要其他顺序或筛选条件,则必须在第一页上重新开始。还需要注意的是,页面引用通常是临时的不需要保存。

多对多关系

有些时候两个资源之间需要创建多对多关系,你可以创建一个新的资源代表这个关系,让我们看如下的例子:
有两个资源,学生和课程,每个学生可以对课程进行评价。我们创建一个新的资源,代表学生和课程之间的关系,我们称它为 学生-课程-评价 资源。

1
2
3
4
5
6
// 增加一个 学生-课程-评价 关系
POST /student-course-rates
// 列出所有的学生-课程的评价,根据学生或者课程过滤
GET /student-course-rates
// 根据ID删除评价
DELETE /student-course-rates/:id

一个学生可以增加一个课程的评价:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# REQUEST
POST /student-course-rates HTTP/1.1
Host: example.com
Content-Type: application/json
{
"studentId": "3298wdi28dh28wid92",
"courseId": "93710949600282",
"rate": 10
}

# RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"id": 1239836164989016,
"student": {
"id": "3298wdi28dh28wid92",
"age": 18
},
"course": {
"id": "93710949600282",
"description": "..."
},
"rate": 10
}

获取课程所有的评价:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# REQUEST
GET /student-course-rates?course=93710949600282&limit=10 HTTP/1.1
Host: example.com

# RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"items": [
{
"id": 1239836164989016,
"student": {
"id": "[email protected]",
"age": 18
},
"course": {
"id": "93710949600282",
"description": "..."
},
"rate": 10
},
// other results ...
]
}

我们看到,一个 学生-课程-评价 资源具有一个标识符,即属性id,并使用关联对(studentId,courseId),这样可以通过查询参数删除过滤删除。 DELETE /student-course-rates?course=xxx&student=xxx

字段筛选

有时候由于性能原因,客户端需要选择应在响应中包括哪些属性,即要求查询参数字段包含用逗号分隔的属性列表。
获取用户资源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# REQUEST
GET /users/12fw342ej1 HTTP/1.1
Host: example.com

#RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"id": "12fw342ej1",
"name": {
"familyName": "Muro",
"givenName": "Rupert"
},
"age": 67
}

客户端定义返回的参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
# REQUEST
GET /users/12fw342ej1?fields=name.familyName%2Cage HTTP/1.1
Host: example.com

#RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"name": {
"familyName": "Muro"
},
"age": 67
}

我们还可以将字段的子集映射成预定义的 style,这样客户端可以选择 style 来返回需要的预定义的字段。
例如,我们可以将字段 idname.familyNameage 映射到 compact style ,将 idname.familyNamename.givenNameage 映射到 complete style,查询时候使用不同的 style 即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# REQUEST the compact style 
GET /users/12fw342ej1?style=compact HTTP/1.1
Host: example.com

# RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"id": "12fw342ej1",
"name": {
"familyName": "Muro"
},
"age": 67
}

# REQUEST the complete style
GET /users/12fw342ej1?style=complete HTTP/1.1
Host: example.com

# RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"id": "12fw342ej1",
"name": {
"familyName": "Muro",
"givenName": "Rupert"
},
"age": 67
}

长时间运行的操作

为了提高可伸缩性并简化部署,Web服务响应时间必须尽可能短,但是有时我们需要计算长时间运行的操作,我们该怎么做?
首先,创建一个代表需要长时间运行的资源,当客户端向该资源提交 GET 请求时,根据操作的当前状态进行如下响应:

  • 操作仍在运行:返回状态代码 200(Ok),并表示操作状态。
  • 操作结束并成功:返回状态码 303(See Other)Location header,其中包含创建的资源的 URI。
  • 操作结束并失败:返回态码 200(Ok) ,并提供有关失败的信息。

让我们看一个例子,设计一个从 URI 提取摘要的 Web 服务,我们有两种资源,摘要和提取任务:

1
2
3
4
5
6
7
8
// 根据 ID 获取摘要信息
GET /summary/:id

// 创建一个需要长时间运行获取摘要的任务
POST /extraction-task

// 更加ID返回任务信息
GET /extraction-task/:id

客户端可以使用 POST 请求创建新的提取任务,服务器返回状态码 202(Accepted),并返回任务相关的信息(例如: 客户端下一次检查任务的时间 checkAfter ) 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# REQUEST
POST /extraction-task HTTP/1.1
Host: example.com
Content-Type: application/json
{
"from": {
"uri": "https://extract.from.here.com"
}
}

# RESPONSE
HTTP/1.1 202 Accepted
Content-Type: application/json;charset=UTF-8
Content-Location: https://example.com/extraction-task/348wd39

{
"id": 348wd39,
"state": "pending",
"checkAfter": "2019-01-10T22:32:12Z",
"info": {
"from": {
"uri": "https://extract.from.here.com"
}
}
}

然后,客户端可以使用 GET 请求查询任务状态,如果服务端仍在处理该任务,它将返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# REQUEST
GET /extraction-task/348wd39 HTTP/1.1
Host: example.com

# RESPONSE
HTTP/1.1 202 Accepted
Content-Type: application/json;charset=UTF-8
{
"id": 348wd39,
"state": "pending",
"checkAfter": "2019-01-10T22:32:12Z",
"info": {
"from": {
"uri": "https://extract.from.here.com"
}
}
}

服务器成功完成操作后,将返回 303(See Other),这意味着可以使用 GET 方法在另一个 URI 获取结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# REQUEST
GET /extraction-task/348wd39 HTTP/1.1
Host: example.com

# RESPONSE
HTTP/1.1 303 See Other
Location: https://example.com/summary/239rfh392
Content-Location: https://example.com/extraction-task/348wd39
{
"id": 348wd39,
"state": "completed",
"info": {
"from": {
"uri": "https://extract.from.here.com"
}
},
"finishDate": "2019-01-10T22:35:11Z"
}

如果任务已经失败:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# REQUEST
GET /extraction-task/348wd39 HTTP/1.1
Host: example.com

# RESPONSE
HTTP/1.1 200 OK
Location: https://example.com/summary/239rfh392
Content-Location: https://example.com/extraction-task/348wd39
{
"id": 348wd39,
"state": "failed",
"info": {
"from": {
"uri": "https://extract.from.here.com"
}
},
"finishDate": "2019-01-10T22:35:11Z",
"detail": "The URI doesn't exist (status code 404)."
}

如果您希望使用回调的方式,则只需在操作创建过程中给定一个 URI,操作结束时便会使用该 URI 通知客户端:

1
2
3
4
5
6
7
8
9
10
# REQUEST
POST /extraction-task HTTP/1.1
Host: example.com
Content-Type: application/json
{
"from": {
"uri": "https://extract.from.here.com"
},
"notifyOn": "https://client.com"
}

并发处理

一台服务器可以同时为多个客户端提供服务,这增加了出现并发问题的可能。例如,两个客户端使用 PUT 或 POST 同时修改同一个资源。 该解决方案来自(RFC7232)。
条件请求要求服务端在返回响应的 header 中包含 Last-ModifiedETag 一个或两个条件。
客户端在执行修改请求时,必须在 header 中包含 If-Unmodified-SinceIf-Match 中的一个或两个。

让我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# REQUEST
GET /users/12fw342ej1 HTTP/1.1
Host: example.com

# RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
ETag: "abcec491d0a4e8ecb8e14ff920622b9c"
Last-Modified: Sun, 05 Jan 2019 14:14:52 GMT
{
"id": "12fw342ej1",
"name": {
"familyName": "Muro",
"givenName": "Rupert"
},
"age": 67
}

为了符合条件请求,客户端必须在 header 包含 If-Unmodified-SinceIf-Match 中的一个或两个。 如果没有任何内容,则服务端将在响应正文中以 403(Forbidden) 进行回复。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# REQUEST
PUT /users/8646291 HTTP/1.1
Host: example.com
Content-Type: application/json
{
"age": 54
}

# RESPONSE
HTTP/1.1 403 Forbidden
Content-Type: application/json;charset=UTF-8
{
"code": "120",
"message": "The conditional headers are required; If-Unmodified-Since and/or If-Match"
}

如果匹配到更新的资源,它可以处理更新并返回 200(OK)204(No Content)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# REQUEST
PUT /users/8646291 HTTP/1.1
Host: example.com
If-Unmodified-Since: Sun, 05 Jan 2019 14:14:52 GMT
If-Match: "abcec491d0a4e8ecb8e14ff920622b9c"
Content-Type: application/json
{
"age": 54
}

# RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
ETag: "1e0e5a0fb102db75aa36d4356936fe4c"
Last-Modified: Sun, 05 Jan 2019 14:15:02 GMT
{
"id": "8646291",
"age": 54
}

如果不是,则服务端必须返回状态代码 412(Precondition Failed),并说明原因。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# REQUEST
PUT /users/8646291 HTTP/1.1
Host: example.com
If-Unmodified-Since: Sun, 05 Jan 2019 13:14:52 GMT
If-Match: "b1b3833b514f4b4a5207b572405e786f"
Content-Type: application/json
{
"age": 54
}

# RESPONSE
HTTP/1.1 402 Precondition Failed
Content-Type: application/json;charset=UTF-8
{
"code": "121",
"message": "The provided conditional headers doesn't match current values; The request rely on stale informations"
}

版本控制

有时我们需要对 API 进行版本控制,因为提供不同的版本会极大地提高对 API 的理解和维护。
我们可以通过 headerAcceptContent-Type 进行版本控制。
让我们看一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# REQUEST VERSION 1
GET /users/12fw342ej1 HTTP/1.1
Host: example.com
Accept: application/json;version=1

# RESPONSE VERSION 1
HTTP/1.1 200 OK
Content-Type: application/json;version=1;charset=UTF-8
{
"id": "12fw342ej1",
"familyName": "Muro",
"givenName": "Rupert"
"age": 67
}


# REQUEST VERSION 2
GET /users/12fw342ej1 HTTP/1.1
Host: example.com
Accept: application/json;version=2

# RESPONSE VERSION 2
HTTP/1.1 200 OK
Content-Type: application/json;version=2;charset=UTF-8
{
"id": "12fw342ej1",
"name": {
"familyName": "Muro",
"givenName": "Rupert"
},
"age": 67
}

资源聚合(门面模式)

有时需要在从同一地方获取多个资源,客户端必须调用多个接口,然后组合所需资源并展示。
根据客户端使用模式,性能和延迟要求,我们可以创建一个聚合多个资源的新资源,提高易用性和性能。

举个例子,我们需要在一个页面来显示用户的财务状况,该页面需要显示,用户信息、前10个投资、后10个银行记录、总余额、信用卡限额。如果分别请求每个资源都会导致性能问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# REQUIRE EACH REQUEST
GET /users/12fw342ej1 HTTP/1.1
Host: example.com
Accept: application/json

GET /investiments?user=12fw342ej1 HTTP/1.1
Host: example.com
Accept: application/json

GET /bank-records?user=12fw342ej1 HTTP/1.1
Host: example.com
Accept: application/json

GET /credit-card?user=12fw342ej1 HTTP/1.1
Host: example.com
Accept: application/json

GET /bank-account/ew239wqw21ui32une HTTP/1.1
Host: example.com
Accept: application/json

为了解决该问题,我们可以创建一个汇总结果的资源,称为财务报告,并且由于它与用户关联,因此可以作为用户的子资源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# REQUIRE EACH REQUEST
GET /users/12fw342ej1/financial-report HTTP/1.1
Host: example.com
Accept: application/json

# RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"userInfo": { .. },
"lastInvestiments": [...],
"lastBankRecords": [...],
"bankAccount": 12321,
"creditCartLimits": {...}
}

多语言

HTTP 提供了两个用于语言协商的 header 用来处理语言。客户端提供 Accept-Language ,用来通知服务器有关首选语言的信息,Content-Language 由服务端在响应中提供。
让我们看一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# REQUEST
GET /products/782hb1yufhd8923 HTTP/1.1
Host: example.com
Accept-Language: en,en-US,it
Accept: application/json

# RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Content-Language: en
Vary: Accept-Language
{
"id": "782hb1yufhd8923",
"description": {
"localizedValue": "This is a description",
"translations": [
{
"lang": "en",
"value": "This is a description"
},
{
"lang": "it",
"value": "..."
}
]
}
}

参考

RESTful API Patterns

.git 目录有哪些东西

当你使用 git init 命令创建一个 Git 仓库,Git 将创建一个 .git 的文件夹。这个文件夹包含 Git 工作的所需要用到的所有信息。 要清楚,如果你想在你的项目中删除 Git,但需要保留项目文件,你只需要删除 .git 文件夹即可。

如下你在第一次提交前 .git 文件夹里的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|____.git
| |____config
| |____objects
| | |____pack
| | |____info
| |____HEAD
| |____info
| | |____exclude
| |____description
| |____hooks
| | |____commit-msg.sample
| | |____pre-rebase.sample
| | |____pre-commit.sample
| | |____applypatch-msg.sample
| | |____fsmonitor-watchman.sample
| | |____pre-receive.sample
| | |____prepare-commit-msg.sample
| | |____post-update.sample
| | |____pre-applypatch.sample
| | |____pre-push.sample
| | |____update.sample
| |____refs
| | |____heads
| | |____tags
  • HEAD
    下文将详细介绍

  • config
    这个文件包含你的 Git 仓库的配置信息,这里记录了远程的 url、邮箱、用户名等信息,每次你使用 git config --local -l 命令,将展示此文件的配置信息。

  • description
    用于 gitweb (比方说 github) 来展示 Git 仓库的描述信息。

  • hooks
    这是一个非常有趣的特性。他们是 Git 自带的一组脚本。 Git 会在每个有意义的阶段自动运行这些脚本。这些脚本称作为 hooks。他们在执行 commitrebasepull … 之前或之后运行这些脚本,脚本的名称表示了何时将运行他们。举个例子,pre-push 脚本将在执行 git push 命令之前执行,目的是为了做提交到远程前的一些检查,保持远程仓库和本地仓库的一致性。

  • info/exclude
    通常我们是将不希望 Git 管理的文件放在 .gitignore 配置文件中。exclude 文件和.gitignore 文件作用相同,只是不会被共享。例如,你不想让 Git 管理跟踪于自定义的 IDE 产生的相关的文件,又不希望放在 .gitignore 中被提交上去,就可以用这种方式,虽然这种方式实在没有必要。

提交的内容是什么

每次你创建一个文件,并且跟踪他,Git 将它压缩,并用自己的数据结构保存。被压缩的对象有一个唯一的名字和哈希,存储在 objects 文件夹下。
在浏览 objects 文件夹前,我们必须明白是么是一次 commitcommit 是一种你工作目录的快照(snapshot),但这还不止于此。
实际上,当您提交 Git 时,只有两件事可以创建工作目录的快照:

  1. 如果文件没有改变,Git 仅仅将被压缩文件的名称(哈希)记录到快照中。
  2. 如果文件发生改变,Git 将压缩他,并且将压缩文件存储到 objects 文件夹中。最后,它将压缩文件的名称(哈希)添加到快照中。
    这是简化的介绍,整个过程会有点复杂,将在以后的文章中介绍。
    一旦创建了快照,快照也将被压缩并以哈希命名,所有这些压缩对象都存放在 objects 文件夹中。
1
2
3
4
5
6
7
8
|____93
| |____81648a3b1fe0eb310bdd9c87f001f83e132375 // 提交信息
|____86
| |____550c31847e518e1927f95991c949fc14efc711 // 工作目录快照
|____e6
| |____9de29bb2d1d6434b8b29ae775ad8c2e48c5391 // 文件 hash
|____pack // let's ignore that
|____info // let's ignore that too

这是我创建了一个空文件 file_1.txt 并且提交了它之后 objects 文件夹的内容。如果你的文件哈希值是 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391,Git 将存储这个文件到 e6 子文件夹中,并将文件名命名为 9de29bb2d1d6434b8b29ae775ad8c2e48c5391 , 这种方法会使 objects 文件夹数量最多为 255 个(00 ~ FF)
这里我们看到 3 个哈希值,一个是我们创建的 file_1.txt 文件,一个是我们 commit 时创建的工作目录快照,那另一个是什么呢? 因为提交本身就是一个对象,所以它也被压缩并存储在 objects 文件夹中。
我们需要记住一次 commit 会创建4个信息:

  1. 各个被 git add,并发生修改、增加、删除文件本身的哈希文件
  2. 工作目录快照哈希文件(修改、增加、删除的文件列表)
  3. 提交者的信息哈希文件,包含作者、commit message
  4. 上一次提交的hash

我们解压缩一下提交的哈希文件

1
2
3
4
5
6
➜ git cat-file -p 9381648a3b1fe0eb310bdd9c87f001f83e132375
tree 86550c31847e518e1927f95991c949fc14efc711
author liuhu <[email protected]> 1583582827 +0800
committer liuhu <[email protected]> 1583582827 +0800

commit a file

我们看到工作目录快照的哈希信息、作者、和 commit message。
这里有两个信息非常重要:

  1. tree 86550c31847e518e1927f95991c949fc14efc711 工作目录快照信息
  2. 由于是第一次提交,没有上一次提交的哈希

如果你再修改一下这个文件,就可以看到 parent 上一次提交的哈希信息

1
2
3
4
5
6
7
➜ git cat-file -p dbe709db4cda426395cf23fe3063f18128f339e7
tree e2415f5143e84735cbd3d6b8a65c76d2e74c5b61
parent 5d23d6c0468fc867de009ad4eb33ec098c0f5caf
author liuhu <[email protected]> 1583585937 +0800
committer liuhu <[email protected]> 1583585937 +0800

modify file

工作目录快照信息:

1
2
➜ git cat-file -p 86550c31847e518e1927f95991c949fc14efc711
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 file_1.txt

在这里,我们找到了提交文件本身的哈希 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391

branch, tags, HEAD 都是指针

我们现在了解了 Git 中的所有内容都是使用正确的哈希值进行访问的。
现在让我们看一下 HEAD 中有什么内容:

1
2
➜ cat HEAD
ref: refs/heads/master

HEAD 表示的是当前分支的指针信息,我们在看看 refs/heads/master 是什么内容:

1
2
➜ cat refs/heads/master
9381648a3b1fe0eb310bdd9c87f001f83e132375

似曾相识的信息,他就是我们第一次提交的哈希信息。这说明分支、标签都是执行提交的指针。这意味着你可以删除所有分支、标签,但他们的提交都保留着,这只会给我们的访问带来一定的困难。如果你想了解更多,你可以阅读 gitbook

原文链接

Git series 1/3: Understanding git for real by exploring the .git directory