【读】17 REASONS NOT TO BE A MANAGER
介绍
这是一篇出现在 Hacker News
上的文章,作者阐述了关于不当管理者的17个理由。
17个理由
1. 你热爱你所做的事
- 你会为能亲自实现某个功能而兴奋不已吗?
- 你会时而在敲完一天代码后开心的哼着小曲下班吗?
- 你会为过去所实现的功能、达成的成就感到自豪吗?
如果是的话,那么你是一个幸运的打工人。永远不要低估一颗热爱工作的心,同时也不要想当然的认为无论何时都能重拾这份热爱。
2. 找到一份工程师的工作很简单
在裁员时代,这点已经不再简单。不过在同等条件下,工程师岗位相对于管理者岗位来说:
- 技能更能够量化,仅从通过面试来说,一部分技能甚至可以在面试过程中不断学习强化
- 除了特定行业外,工程师的技能一般不和公司深度绑定。在上一家公司培养的技能并不会因为换了家公司就基本没用;而管理者在上一家公司与各团队构建的信任关系则一般无法带到下一家公司,或者换了一家公司后,需要应对未曾遇到的人员关系
3. 管理者岗位僧多粥少
管理者岗位一个萝卜一个坑,其招聘数量远少于工程师岗位。
4. 管理者最先被炒鱿鱼
如果真要裁员,光裁管理者是不够的,工程师反而有天然的【人数优势】。
除非是一锅端,否则各部门按比例的裁员场景下,应该不会有管理者自告奋勇的说自己产生不了直接价值,底下的人离开我也能转,裁我吧。
5. 管理者不易跳槽
除开管理者岗位本身的原因,年龄也有一定的影响,但这不仅仅针对管理者。管理者的年龄一般比下属的工程师大,即使一个工程师在年轻的时候可以一年两跳,到了管理者同样的年纪也可能会变得不容易跳槽。
6. 工程师会看轻管理者
大家都是打工的,没有必要谁看不起谁。当然也会有唯技术论的工程师,无视技术之外的一切;但同样的,也有始终认为自己是主子的管理者,这种,自然是没有必要迎合的。
那么,管理者需不需要懂技术?如果是放权型管理者,能安心将技术决策委托给核心工程师,是可以不用懂技术的,从而专注发挥好自己的管理长处。不过,这属于可遇不可求的情况,现实中没有那么多的刘备和孔明。虽然作者和他的同事们讨论后都认为所遇到的优秀的管理者都不懂技术,但是优秀的管理者本身是比优秀的工程师更为稀缺的存在。所以,对于一般的管理者,至少在技术上要能认识到十个女人一个月真的生不出孩子。
7. 管理者有时候要当坏人
身为管理者,难免会遇到以下的情况:
- 绩效有人要背 C
- 裁员指标
- 传达上头不合理的要求
而并不是所有人都愿意和能合理的处理好这些场景。
8. 管理者的技能树比你想象中的要少
如果从工程师切到了管理者,可能会觉得自己一直在飘着,不再是实际的执行者,这对于某些工程师来说可能会比较难受。而另一方面,立志往管理线发展的人可能会更乐于去做引导一个产品或项目落地的过程,对实际执行并不太关心,并在这期间逐步提高自己的影响力。
我认为这里能体现管理者水平的地方包含但不限于如何处理:
- 你的目标对你很重要,但对其他人不重要
- 你有雄心壮志,但其他人只想安分守己
9. 做得好是你的本分,做不好是你的锅
大和田老师在半泽直树1里说过:
下属的功劳是上司的功绩,上司的过错是下属的责任
做不好又能把锅甩出去也是管理的一种能力。
10. 你需要以 IC 的身份和管理者分庭抗礼
IC
全称 Individual Contributor
,常见翻译为独立贡献者或个人贡献者。IC
最明显的特点是没有管理职责,注意不等同于没有管理工作,他们利用自己的专业水平协同或者独立完成任务,最终可能成为某一方面的专家。
IC
也分等级,例如 Dropbox
的软件工程师职位就划分为了 IC1
到 IC7
,而管理者岗位则是 M
线。高级的 IC
也会有管理工作,例如项目管理(高级 IC
负责的项目很可能已经不是自己能独立完成的了)或者人员管理(什么地方用什么样的人)。
这里作者认为需要有能够发声的高级 IC
,因为他们毕竟还是 IC
线,他们所代表的利益有时也符合普通工程师的利益。如果高级 IC
最终都转到了管理岗,那么本来就人微言轻的普通工程师的利益也更难传达到上层。不过,这也要求公司有能够让高级 IC
开花结果的土壤。
11. 管理只是一系列技能,你同样能以 IC
的身份去尝试所有有趣的管理工作
随着在 IC
路线上的成长,你会逐渐涉及一些技术之外的管理工作。有人可能就会乐于去尝试这些管理工作,例如担任导师,面试,参与决策,制定职业规划等。作者认为一个健康的公司应当鼓励并允许高级 IC
去参与这些工作。这样就避免了参与管理者职责中的一些不讨喜的活,例如绩效考核,裁人等。
12. 更难从工作中感到愉悦
修复一个问题或者学习新知识所带来的愉悦可能就此一去不复返,同时,工作中的正反馈周期也可能变长。
不过,这也因人而异,那些享受改完一个高深 Bug
的工程师可能根本不会想着做管理,而有些做管理的人也可能根本不认为改完一个高深的 Bug
是种享受,他们的愉悦点可能在于来自底下的服从。
13. 情绪影响会衍生甚至占据你的个人生活
身为管理者后,会与更多的人打交道,而人不是一个确定的个体,每个人有各自的行为处世,你可能会觉得更心累。
14. 你的时间不再属于你
普通工程师的时间都不能够一定保证,管理者可能更甚。
15. 会议
更恐怖的是无尽的低效会议。
16. 如果你的心之所向是技术引领
成为管理者后,你的做事方式就转变为了影响团队,提高团队。你的技术水平也会因此停滞不前,然后逐渐衰退。如果你认为这是一种折磨,那么你就不适合成为管理者。
17. 管理者岗位始终会等着你
即使是技术路线越往上走也越会要涉及管理工作,如果你不在乎一个头衔,又何必急于一时。
最后
理想的情况下自然是合适的人在合适的位置上,不过现实中也会有赶鸭子上架而做了管理者的人,或者为了延长自己的职业寿命而无奈转了管理者。但无论如何,管理并不是一个想当然的工作,并不是因为工程师干不下去了所以到时候就转管理,这既不尊重管理岗位本身,也不尊重团队中的其他人,只会多一个不靠谱的管理者,而不靠谱的管理者比不靠谱的工程师更糟糕。
参考
通过 Grafana Agent 上传 Prometheus 指标数据到 Grafana Cloud
介绍
Grafana Cloud
为免费账户提供了一万条指标的存储额度,对于业余项目来说可以考虑将指标上传到由 Grafana Cloud
托管的 Prometheus
中。
安装 Grafana Agent
Prometheus
指标数据的上传需要通过 Grafana Agent
来完成,以下安装步骤以 Ubuntu
为例:
1 | mkdir -p /etc/apt/keyrings/ |
安装完成之后通过 sudo systemctl start grafana-agent
将其启动,并可通过 sudo systemctl status grafana-agent
显示 grafana-agent
的当前状态:
1 | ● grafana-agent.service - Monitoring system and forwarder |
同时,如果希望系统重启后自动启动 grafana-agent
服务,可以执行如下的命令:
1 | sudo systemctl enable grafana-agent.service |
另外,可以通过 sudo journalctl -u grafana-agent
查看 grafana-agent
的运行日志:
1 | -- Logs begin at Sun 2021-12-26 04:48:21 UTC, end at Sun 2023-03-12 06:34:53 UTC. -- |
上报监控数据
grafana-agent
上报的监控数据分两种,一种是 grafana-agent
自身及其所在主机的监控数据,另一种是自定义服务的监控数据,我们需要修改 grafana-agent
的配置文件来指定如何收集监控数据。
grafana-agent
的默认配置文件为 /etc/grafana-agent.yaml
:
1 | # Sample config for Grafana Agent |
自定义服务的监控数据收集需要定义在 metrics.configs
下,grafana-agent
自身及其所在主机的监控数据收集默认已经是开启的。
假设需要收集由 Spring Boot
的 actuator
模块所暴露的 Prometheus
监控数据,则需要在 metrics.configs
下新增如下类似配置:
1 | - name: 'My Spring Boot App' |
最后,再通过 remote_write
设置将监控数据推送到 Grafana Cloud
下的 Prometheus
:
1 | remote_write: |
其中 url
,username
和 password
这三个信息都可以在所创建的 Grafana Cloud Stack
下的 Prometheus
的详情页中找到。password
对应 Grafana Cloud API Key
,如果之前没有创建过的话需要新生成一个,角色选择 MetricsPublisher
即可:
完整的配置文件示例如下:
1 | server: |
配置文件修改完成之后,通过 sudo systemctl restart grafana-agent
来重启 grafana-agent
服务。
Grafana 展示
对于自定义服务的监控展示使用自己熟悉的方式即可,例如 Java
应用可以配合使用 JVM (Micrometer)。
对于 grafana-agent
自身的监控展示可以结合 agent-remote-write.json:
最后,grafana-agent
对于所在主机的监控展示可以借助 Node Exporter Full:
参考
【读】Google API Design Guide
介绍
Google API Design Guide 是 Google
设计 Cloud APIs 和其他 Google APIs 的设计指南。
该指南面向的不仅仅是 REST APIs
,同时也适用于 RPC APIs
,其中 RPC APIs
主要面向的是 gRPC APIs
。
面向资源的设计
传统的 RPC
接口设计面向的是操作,各个接口之间是孤立的,没有明确的关联;不同系统的接口也有着不同的设计风格,存在着一定的学习和使用成本。
而面向资源的设计则将系统抽象为一系列资源,开发者则通过有限的几个标准方法来操作资源,从而实现对系统的修改。对于 RESTful
接口来说,有限的几个标准方法对应的就是 HTTP
请求方法中的 POST
,GET
,PUT
,PATCH
和 DELETE
。另一方面,由于遵循了统一的设计,当开发者调用不同系统的接口时,能够自然的假定各个系统都支持相同的标准方法,从而降低了开发者学习的成本。
不管是面向资源的设计还是其他的设计标准,统一的标准胜过百花齐放,开发者应当将更多的精力放在自身系统的业务实现上,而不是耗费时间学习和调试其他系统的接口。
该指南建议按照如下的步骤设计面向资源的接口:
- 确定接口所提供的资源类型
- 确定资源间的关系
- 根据资源类型和资源间的关系确定资源命名模式
- 确定资源体系
- 为每个资源设计最小限度的操作方法
在面向资源的设计体系下,各接口一般会按照资源的层级结构进行组织,层级结构中的每个节点可能是单一的资源,也可能是一个资源集合:
- 一个资源集合包含了一系列相同类型的资源,例如,一个用户拥有一个联系人资源集合
- 一个资源包含了若干的状态,同时也包含了0个或者多个子资源。每个子资源可以是一个单一资源或者是资源集合
以创建邮件接口为例,传统的接口设计可能是如下的方式:
1 | POST /createMail |
而面向资源的接口设计则可能为:
1 | POST /users/{userId}/mails |
可以看到,面向资源的接口设计体现了资源间的层级关系。一般而言,对于 RESTful
接口来说,请求 URL
中只会包含资源的名称(名词),而不会包含对资源的操作(动词),HTTP
的请求方法就对应了资源的标准操作方法。而该指南讨论的是通用的面向资源的设计,其对应的资源标准操作方法为:List
,Get
,Create
,Update
和 Delete
。
资源名称
在面向资源的设计下,资源是一个命名实体,每个资源都有一个唯一的名称作为其标识符。一个资源的名称由三部分组成:
- 资源的
ID
- 所有父资源的
ID
API
服务名,如gmail.googleapis.com
资源集合被视为一种特殊的资源,它包含了一组相同类型的子资源,例如一个目录可以被视为一个资源集合,它包含了一组文件资源。同时,资源集合也有相应的 ID
。
资源名称由资源 ID
和资源集合 ID
组成,其定义也体现了资源的层级结构关系,各层级之间使用 /
进行分隔。例如,对于某个对象存储服务中的对象来说,其资源名称可能为 //storage.googleapis.com/buckets/bucket-123/objects/object-123
,其中最顶层为服务名,即 //storage.googleapis.com
,然后是一个资源集合,即 buckets
,对象存储服务一般以 bucket
为维度来管理对象;接下来为了要定位到某个对象,需要先定位到具体的 bucket
,bucket-123
就是某个 bucket
的资源 ID
,而每个 bucket
下包含了多个对象,进而产生了一个资源集合 objects
,最后的 object-123
就是实际对象的资源 ID
。
一般来说,一个资源在实现上很可能对应一张数据库的表,所以可以用表的主键来作为资源的 ID
。而由于使用了 /
来分隔资源的层级,因此只有最底层的资源才允许资源 ID
中包含 /
,从而避免层级歧义。
如果资源
ID
中包含了/
,则必须在API
文档中明确声明。
资源集合更多的是一种层级上的逻辑概念,所以其 ID
命名需要有意义,以及符合以下的规范:
- 必须是以小驼峰形式命名的英文单词复数,如果对应单词没有复数,则应当使用单词的单数形式
- 必须使用简洁明了的英文单词
- 避免使用过于宽泛的英文单词,例如,
rowValues
优于values
。同时应当避免无条件的使用如下的英文单词:- elements
- entries
- instances
- items
- objects
- resources
- types
- values
对于
ID
还会经常出现在自动生成的客户端类库代码中,所以它们的命名也必须是合法的C/C++
标识符。
完整的资源名称是协议无关的,虽然它看起来像 RESTful
服务的 HTTP
接口请求路径,但本质上这是两个东西。实际的资源请求还需要附带版本号,协议等信息,例如对于资源名称 //calendar.googleapis.com/users/john smith/events/123
来说,实际的 RESTful
请求路径可能是 https://calendar.googleapis.com/v3/users/john%20smith/events/123
,和原本的资源名称相比有三点不同:
- 指明了具体的协议,
HTTPS
- 指明了版本号,
v3
- 对资源名称进行了
URL
转义
Google
的 API
服务要求资源名称必须是字符串,除非有后向兼容的问题,资源名称在跨模块间传递时必须确保没有任何数据丢失。对于资源定义来说,第一个字段应该命名为 name
,类型为字符串,用于表示资源的名称,以下是 gRPC
服务的定义示例:
1 | // 图书馆服务 |
为什么这里资源的名称字段要定义为
name
而不是id
?首先从命名上来说name
本身要比id
更适合作为名称
一词的命名。其次,name
也是一个较为宽泛的词语,例如文件资源的name
代表的是文件的名称还是完整的路径?通过将name
作为标准字段,使得开发人员必须要选择更适合的命名,例如display_name
,title
或者full_name
。
为什么不直接使用资源 ID
来定位资源?一个系统中往往有多个资源,单纯的资源 ID
不具有辨识度以及缺少上下文信息。例如,如果使用数据库表的自增主键作为资源 ID
,则无法简单的通过数字来定位资源。如果想要通过资源 ID
来定位资源,则势必要扩展资源 ID
的定义,例如使用元组来表示资源 ID
,如 (bucket, object)
用于定位某个对象存储服务的对象。不过,这也带来了几个问题:
- 对开发人员不友好,需要额外理解和记忆(例如不同资源
ID
的元组元素个数不同,每个元组元素代表的含义是什么) - 解析元组比解析字符串更为困难
- 对基础设施组件不友好,例如日志和访问控制系统无法直接理解元组
- 限制了
API
设计的灵活性,如提供可复用的API
接口
标准方法
标准方法的作用在于为大多数的服务场景提供统一、易用的接口,超过 70% 的 Google APIs
都是标准方法。Google APIs
设计了5种标准方法:
List
Get
Create
Update
Delete
下表是标准方法和 HTTP
请求方法的映射:
标准方法 | HTTP 请求方法映射 |
HTTP 请求体 |
HTTP 响应体 |
---|---|---|---|
List | Get <资源集合 URL > |
无 | 资源集合 |
Get | GET <资源 URL > |
无 | 资源 |
Create | POST <资源集合 URL > |
资源 | 资源 |
Update | PUT 或者 PATCH <资源 URL > |
资源 | 资源 |
Delete | DELETE <资源 URL > |
无 | 空 |
HTTP
响应体返回的资源可能不会包含资源的全部字段,例如客户端请求时可以指定只返回需要的字段。
如果Delete
操作不是立即删除资源,例如只是更新资源的某个字段标记删除或者是创建一个 长时间运行任务 来删除资源,则HTTP
响应体应该包含修改后的资源或者任务信息。
List
List
方法用于返回一系列同类的资源,同时该接口支持额外的参数从而允许只返回匹配的资源。它适合用于获取有限大小、未缓存的单一资源集合,对于更复杂的场景则可以考虑使用自定义方法中的 Search
接口。
如果想要批量获取资源,例如入参一组资源 ID
来返回每个资源 ID
所对应的资源,则应该考虑实现 BatchGet
的自定义方法,而不是在 List
方法上扩展。
List
方法和 HTTP
请求的映射关系如下:
List
方法必须对应HTTP
的GET
请求List
方法的RPC
请求消息体的name
字段(也就是资源集合名称)应该和HTTP
的请求路径匹配,如果相匹配,则HTTP
请求路径的最后一个段必须是字面量(即资源集合ID
)List
方法的RPC
请求消息体的其他字段应该和HTTP
请求路径的查询参数相匹配- 对应的
HTTP
请求无请求体;List
方法的API
定义中不允许声明body
语句 HTTP
响应体应该包含一组资源及可选的元数据信息
接口定义示例:
1 | // 获取书架上的书 |
Get
Get
方法接收一个资源名称及其他参数来返回某个指定的资源。
Get
方法和 HTTP
请求的映射关系如下:
Get
方法必须对应HTTP
的GET
请求Get
方法的RPC
请求消息体的name
字段(也就是资源名称)应该和HTTP
的请求路径匹配Get
方法的RPC
请求消息体的其他字段应该和HTTP
请求路径的查询参数相匹配- 对应的
HTTP
请求无请求体;Get
方法的API
定义中不允许声明body
语句 Get
方法返回的资源实体应该和HTTP
的整个响应体相匹配
接口定义示例:
1 | // 获取一本书 |
Create
Create
方法接收一个父资源名称,一个资源实体,以及其他的参数来在指定的父资源下创建一个新的资源,并返回创建的资源。
如果某个 API
服务支持创建资源,则应当为每一类的资源创建对应的 Create
方法。
Create
方法和 HTTP
请求的映射关系如下:
Create
方法必须对应HTTP
的POST
请求Create
方法的RPC
请求消息体应当包含一个parent
字段用于表示所创建的资源的父资源的名称Create
方法的RPC
请求消息体中表示资源实体的各字段应当和HTTP
请求体中的字段相对应。如果Create
方法定义中标注了google.api.http
,则必须声明body: "<resource_field>"
语句Create
方法的RPC
请求消息体可能包含一个<resource>_id
字段来允许调用方指定所创建的资源的id
。这个字段可能会包含在资源字段实体内Create
方法的其余参数应当和URL
的查询参数相匹配Create
方法返回的资源实体应该和HTTP
的整个响应体相匹配
如果 Create
方法允许由调用方指定所创建的资源的名称,并且该资源已经存在,则该请求应当作失败处理并返回错误码 ALREADY_EXISTS
;或者由服务端重新生成一个资源名称,并返回创建的资源,同时接口文档应当清晰的标注最终创建的资源的名称有可能和调用方传入的资源名称不同。
Create
方法的 RPC
请求消息体中必须包含资源实体,这样当资源实体的定义发生变更时,就无需同时变更请求消息体的定义。如果资源实体中的某些字段无法由客户端设置,则必须将其标注为 Output only
字段。
接口定义示例:
1 | // 在书架上创建一本书 |
Update
Update
方法接收一个资源实体,以及其他的参数来更新指定的资源及其属性,并返回更新后的资源。
资源的可变属性应当能够通过 Update
方法修改,除非该属性包含资源的名称或父资源的名称。资源重命名或者将资源移动到另一个父资源下都不允许在 Update
方法中实现,而应当由自定义方法来实现。
Update
方法和 HTTP
请求的映射关系如下:
- 标准的
Update
方法应该能够支持更新资源的部分属性,并通过update_mask
字段来指明需要更新的属性,对应的HTTP
请求方法为PATCH
。资源实体中标注为Output only
的属性应该在资源更新时忽略 - 如果要求
Update
方法实现更为高级的局部更新语义则应当将其作为自定义方法来实现,例如追加新值到资源的某个列表类型的属性上 - 如果
Update
方法不支持局部属性更新,则对应的HTTP
请求方法必须是PUT
。不过不建议Update
方法仅支持全局更新,因为后续如果为资源添加新的属性则可能会有后向兼容问题 Update
方法的RPC
请求消息体中表示资源名称的字段值必须和URL
中的请求路径相匹配。这个资源名称字段可能包含在资源实体内Update
方法的RPC
请求消息体中表示资源实体的各字段必须和HTTP
请求体中的字段相对应Update
方法的其余参数必须和URL
的查询参数相匹配Update
方法的返回结果必须是更新后的资源实体
既然 URL 中已经有了资源名称,为什么请求体里面还要再传一遍资源名称?关于这一点不同的服务有不同的实现,例如
DigitalOcean
的更新接口就不要求请求体中再传一遍id
:Update an App。
Update
方法的返回结果必须是更新后的资源实体看起来是多此一举,但是某些资源的属性必须由服务端来更新,例如资源的更新时间,或者对于Git
服务来说文件更新后的版本号等等,这些属性更新后也需要返回给客户端。
如果后端服务允许客户端指定资源名称则 Update
方法允许客户端调用时发送一个不存在的资源名称,然后服务端会自动创建一个新的资源。否则,Update
方法应当作失败处理并返回 NOT_FOUND
的错误码(如果这是唯一的错误的话)。
即使 Update
方法本身支持新建资源也应当提供额外的 Create
方法,否则服务的使用者可能会感到迷惑。
接口定义示例:
1 | // 更新一本书 |
Delete
Delete
方法接受一个资源名称和其他参数来删除或者计划删除某个指定的资源。Delete
方法返回的消息体类型应当为 google.protobuf.Empty
。
服务调用方不应该依赖 Delete
方法返回的任何信息,因为 Delete
方法被重复调用时不一定每次都返回相同的信息。
Delete
方法和 HTTP
请求的映射关系如下:
Delete
方法必须对应HTTP
的DELETE
请求Delete
方法的RPC
请求消息体中表示资源名称的字段值应当和URL
中的请求路径相匹配Delete
方法的其余参数应当和URL
的查询参数相匹配- 对应的
HTTP
请求无请求体;Delete
方法的API
定义中不允许声明body
语句 - 如果
Delete
方法在实现时是立即删除资源则该方法返回的消息体为空 - 如果
Delete
方法在实现时是创建一个 长时间运行任务 来删除资源,则该方法返回的消息体应当为对应的任务信息 - 如果
Delete
方法在实现时仅将资源标记为删除而不是物理删除,则该方法应当返回更新后的资源
Delete
方法应当是幂等的,但并不要求每次都返回相同的信息。对同一个资源的多个 Delete
请求应当使得该资源(最终)被删除,不过只有第一个成功删除资源的请求应当返回相应的成功信息,其余的请求应当返回 google.rpc.Code.NOT_FOUND
错误码。
接口定义示例:
1 | // 删除一本书 |
自定义方法
标准方法提供了对资源的基础操作功能,它们的职责都较为单一,基本上对应了基础的 CRUD
操作。不过,并不是所有对资源的操作都能或者适合抽象为 CRUD
操作,这也是对于 RESTful
风格的服务经常争论的地方。因此,自定义方法就应运而生。
不过,对于 API
的设计者而言应当尽可能的首选使用标准方法,因为标准方法有着更为统一的语义,对开发者而言更为简单易懂。
自定义方法可以应用于资源,资源集合或者服务。它可能会接收任意类型的输入和返回任意类型的输出,并且支持流式的请求和响应。
HTTP 请求映射
对于自定义方法来说,应当使用如下的 HTTP
请求映射:
1 | https://service.name/v1/some/resource/name:customVerb |
这里之所以选择 :
而不是 /
将 name
和 customVerb
分开是为了支持 name
中包含 /
的情况,例如将文件路径作为资源名称时则获取资源的请求可能为 GET /files/a/long/file/name
,则撤销对该文件的删除操作所对应的自定义方法可能为 POST /files/a/long/file/name:undelete
,如果 undelete
前用 /
分割则无法识别完整的资源名称。
自定义方法和 HTTP
请求的映射关系应当遵循如下规则:
- 自定义方法应当使用
HTTP
请求的POST
方法,除非该自定义方法是作为标准方法中的List
或者Get
方法的扩展,此时可以使用HTTP
请求的GET
方法 - 自定义方法不应该使用
HTTP
请求的PATCH
方法,但是可以使用其他HTTP
请求方法 - 对于使用
HTTP
请求的GET
方法的自定义方法来说必须保证接口的幂等性 - 自定义方法的
RPC
请求消息体中表示资源或者资源集合名称的字段值应当和URL
中的请求路径相匹配 HTTP
请求的路径必须以:customVerb
的形式结尾- 如果自定义方法对应的
HTTP
请求方法允许HTTP
请求体(如POST
,PUT
,PATCH
或者自定义的HTTP
方法),则该自定义方法的HTTP
配置中必须声明body: "*"
语句,并且RPC
消息体中的剩余字段应当和HTTP
请求体中的字段相匹配 - 如果自定义方法对应的
HTTP
请求方法不接受HTTP
请求体(如GET
,DELETE
),则该自定义方法的HTTP
配置中不允许声明body
语句,并且RPC
消息体中的剩余字段应当和URL
的查询参数相匹配
接口定义示例:
1 | // 服务级别的自定义方法 |
使用场景
以下是一些自定义方法比标准方法更适合的场景:
- 重启一台虚拟机:其中一种反直觉的设计是在重启资源集合下创建一个重启资源,这属于生搬硬套;或者为虚拟机增加一个状态字段,重启操作就等价于资源的局部更新操作,即将虚拟机的状态由
RUNNING
改为RESTARTING
,虽然看似合理但是增加了使用人员的心智负担,例如除了这两种状态之外还有其他什么状态?另一方面也增加了接口实现的复杂度,标准方法的Update
接口需要额外针对状态字段进行逻辑处理,违背了单一职责原则 - 批处理:对于性能敏感的场景而言,提供批处理的自定义方法比一系列独立的标准方法可能有着更好的性能
对于
RESTful
服务的争论中最常提到的例子就是如何使用RESTful
接口表示注册/登陆?注册/登陆并不适合作为标准方法来实现,使用自定义方法会更好。一般而言,标准方法的实现应当尽量简单直白,一旦需要对标准方法扩展处理额外的逻辑,就需要考虑是否使用自定义方法更合适。
错误处理
错误处理是 RESTful
又一个争论的点,即业务错误可能有很多,HTTP
的状态码根本不够,以及业务的状态码不应该和协议层的状态码相混淆。
错误模型
Google API
将错误统一封装为 google.rpc.Status:
1 | package google.rpc; |
由于大部分的 Google APIs
都是面向资源的设计,错误处理同样遵循了这样的设计,即使用一系列的标准错误来应对大多数的资源错误场景。例如使用标准的 google.rpc.Code.NOT_FOUND
错误来统一表示某个资源不存在,而不是为每一个资源定义一个 SOME_RESOURCE_NOT_FOUND
错误。
错误码
Google APIs
必须使用 google.rpc.Code 中定义的错误码,不允许独自额外定义错误码。
错误信息
错误信息应当能够帮助用户简单快速的理解和解决 API
错误。一般而言,描述错误信息可以遵循如下的规则:
- 不要假设用户是你所开发的服务的专家,他们可能是客户端开发者,运维人员,
IT
人员以及应用的终端用户 - 不要假设用户知晓你所开发的服务任何的实现细节,或者熟悉错误的上下文
- 尽可能的使得错误信息有助于技术用户(不一定是服务的开发人员)响应错误并修正
- 保持错误信息简洁。如果可能的话在错误信息中提供一个帮助链接,以便于用户能够提问,反馈或者查找一些有用的信息
错误信息可能会随时变动,应用开发人员不应该强依赖错误信息。
错误详情
Google APIs
定义了一些列标准的 错误详情,这些错误详情覆盖了大部分的错误场景,例如配额分配失败以及无效的参数等等。和错误码一样,开发人员应当尽可能的优先使用标准的错误详情。
只有当错误详情有助于应用代码处理错误的情况下才应该考虑引入新的错误详情。如果当前错误只能由人工处理,则应依据错误信息由开发人员处理,而不是引入额外的错误详情。
以下是一些错误详情类型的例子:
ErrorInfo
:提供稳定又可扩展的结构化错误信息RetryInfo
:告诉客户端什么时候可以对一个失败的请求进行重试,可能随错误码Code.UNAVAILABLE
或者Code.ABORTED
返回QuotaFailure
:描述为什么配额分配失败了,可能随错误码Code.RESOURCE_EXHAUSTED
返回BadRequest
:客户端请求参数非法,可能随错误码Code.INVALID_ARGUMENT
返回
错误映射
Google APIs
会被不同的编程环境访问,每个环境有自己的错误处理方式,所以需要将前面描述的错误模型对各个编程环境进行适配和映射。
HTTP 映射
对于 HTTP
接口来说,出于后向兼容性的考虑,Google APIs
定义了如下的错误模型:
1 | // 适用于 JSON HTTP 接口的错误模型 |
具体示例:
1 | { |
gRPC 映射
不同的 RPC
协议有着不同的错误处理模式,对于 gRPC
来说,上述的错误模型在各语言自动生成的代码中已经天然支持。
客户端类库映射
不同的编程语言对于错误处理有着不同的准则,客户端类库会尽量去适配这些准则,例如 google-cloud-go
遇到错误时会返回和 google.rpc.Status
实现了同样接口的错误,而 google-cloud-java
则会直接抛出错误。
错误处理
下表列出了 google.rpc.Code
定义的所有错误码:
HTTP | gRPC |
---|---|
200 | OK |
400 | INVALID_ARGUMENT |
400 | FAILED_PRECONDITION |
400 | OUT_OF_RANGE |
401 | UNAUTHENTICATED |
403 | PERMISSION_DENIED |
404 | NOT_FOUND |
409 | ABORTED |
409 | ALREADY_EXISTS |
429 | RESOURCE_EXHAUSTED |
499 | CANCELLED |
500 | DATA_LOSS |
500 | UNKNOWN |
500 | INTERNAL |
501 | NOT_IMPLEMENTED |
502 | N/A |
503 | UNAVAILABLE |
504 | DEADLINE_EXCEEDED |
Google APIs 可能会并发的检查 API 请求是否满足条件,返回某个错误码不代表其他条件都符合要求,应用代码不应该依赖条件检查的顺序。
可以看到,即使是相同的 HTTP
状态码其代表的含义也是不同的,此时就需要 status
字段来进一步区分,类似于将所有错误划分几个大类,然后在每个大类中再细分小类,相比于单纯用 HTTP
状态码来表示不同的错误来说更加灵活,扩展性也更好。
与之相对的非 RESTful
做法则是 HTTP
状态码永远返回200,在返回的消息体中定义错误码和错误信息,例如:
1 | { |
重试错误
对于 503 UNAVAILABLE
错误客户端可以采用 exponential backoff
的方式进行重试,最短重试等待时间应该是1秒,以及默认重试次数应当是1次,除非文档有特别说明。
对于 429 RESOURCE_EXHAUSTED
错误客户端可以等待更长的时间进行重试,不过最短的等待时间应当是30秒。这种重试仅对于长时间运行任务有效。
对于其他的错误,重试可能就不太适合。
错误传播
如果当前服务依赖于其他服务,则不应该直接将其他服务的错误返回给客户端。在进行错误转换时建议:
- 隐藏实现的细节及机密的信息
- 调整负责该错误的一方,例如当前服务从其他服务收到
INVALID_ARGUMENT
错误时则返回INTERNAL
错误给客户端
错误重现
如果通过日志分析和监控无法解决错误,则应该尝试通过简单、可重复的测试来重现错误。
设计模式
空响应
标准方法中的 Delete
方法应当返回 google.protobuf.Empty
,除非 Delete
执行的是软删除,此时应当返回更新后的资源实体。
对于自定义方法来说,应当返回各自的 XxxResponse
消息体,因为即使该方法现在没有数据返回随着时间的推移有很大的可能会增加额外的字段。
范围表示
表示范围的字段应当使用左闭右开的区间,并以 [start_xxx, end_xxx)
的形式命名,例如 [start_key, end_key)
或者 [start_time, end_time)
。API
应当避免其他形式的范围表示,如 (index, count)
或者 [first, last]
。
资源标签
在面向资源的 API
设计下,资源的模式由 API
决定。为了让客户端能够给资源添加自定义的元数据(例如标记某台虚拟机为数据库服务器),资源定义中应当添加一个 map<string, string> labels
字段,例如:
1 | message Book { |
长时间运行操作
如果某个 API
方法需要很长时间才能完成,则该方法应该设计成返回一个长时间运行操作资源给客户端,客户端可以通过这个资源来跟踪方法的执行进展及获取执行结果。Operation 定义了标准的接口来处理长时间运行操作,各 API
不允许自行定义额外的长时间运行操作接口以避免不一致。
长时间运行操作资源必须以响应消息体的方式返回给客户端,并且该操作的任何直接结果都应该反应到其他 API
中。例如,如果有一个长时间运行操作用于创建资源,即使该资源未创建完成,LIST
和 GET
标准方法也应该返回该资源,只是该资源会被标记为暂未就绪。当长时间操作完成时,Operation.response
字段应当包含该操作的执行结果。
长时间运行操作可以通过 Operation.metadata
字段来反馈其运行进展。API
在实现时应当为 Operation.metadata
定义消息类型,即使在一开始的实现中不会填充 metadata
字段。
List 方法分页
支持 List
方法的资源集合应当支持分页功能,即使该方法返回的结果集很小。
因为如果一开始 List
方法不支持分页,后续增加分页功能就会使得和原有 API
的行为不一致。客户端在不知道 List
方法使用分页的情况下依然会认为该方法返回的是完整的资源集合,而实际上有可能只是返回了第一页的资源。
为了兼容旧的逻辑,只能将分页信息设为非必要字段。
为了支持 List
方法的分页功能,API
应当:
- 在
List
方法的请求消息中定义string
类型的page_token
字段。客户端通过该字段来获取指定某页的资源 - 在
List
方法的请求消息中定义int32
类型的page_size
字段。客户端通过该字段来指定每页返回的最大数据量。对于服务端来说,可能会为客户端传入的page_size
大小设置一个上限。如果page_size
的值为0,则由服务端来决定需要返回多少数据 - 在
List
方法的响应消息中定义string
类型的next_page_token
字段。客户端通过该字段来获取下一页的资源,如果next_page_token
的值为""
,则表示没有下一页的资源
接口定义示例:
1 | rpc ListBooks(ListBooksRequest) returns (ListBooksResponse); |
所有分页的实现可能会在响应消息中增加一个 int32
类型的 total_size
字段来表示资源的总个数。
查询子资源集合
有时候客户端可能会希望有一个 API
能够在多个子资源集合间查询资源。例如,某个图书馆 API
有一个书架的资源集合,每个书架包含一个书籍资源集合,客户端可能会希望在多个书架之间搜索某本书。这种情况建议对子资源使用标准的 List
方法,并使用通配符 -
来表示父资源集合,例如:
1 | GET https://library.googleapis.com/v1/shelves/-/books?filter=xxx |
从子资源集合获取唯一资源
有时候某个子资源集合下的资源在全局父资源下有着唯一的标识符。常规的做法是需要先知道该资源的父资源的名称然后才能获取该资源,这种情况建议对该资源使用标准的 Get
方法,并使用通配符 -
来表示父资源集合。例如,如果某本书在所有的书架上有着唯一的标识符,那么可以使用如下的请求来获取该本书籍:
1 | GET https://library.googleapis.com/v1/shelves/-/books/{id} |
另外,该接口返回的资源名称必须是完整的,其父资源名称必须返回实际的值,而不是 -
,例如上述的请求应该返回资源名称 shelves/shelf713/books/book8141
,而不是 shelves/-/books/book8141
。
排序顺序
如果某个 API
允许客户端指定资源的排序顺序,则请求消息体中应该包含一个 order_by
字段:
1 | string order_by = ...; |
order_by
应当遵循 SQL
语法:即使用逗号分割多个字段,例如 "foo,bar"
。默认的排序规则是升序,如果需要降序排序,则应当在字段名后增加 " desc"
后缀,例如 "foo desc,bar"
。
同时,字段间额外的空格是无关紧要的,例如 "foo,bar desc"
和 " foo , bar desc "
是等价的。
请求验证
如果某个 API
有副作用并且需要在执行前验证请求是否有效,则请求消息体中应该包含一个 validate_only
字段:
1 | bool validate_only = ...; |
如果设置为 true
,则该 API
收到请求时仅进行验证而不会实际执行。
如果验证通过,则必须返回 google.rpc.Code.OK
给客户端,并且对于任何有着相同参数的请求都不应该返回 google.rpc.Code.INVALID_ARGUMENT
错误。不过该 API
依然有可能返回其他错误例如 google.rpc.Code.ALREADY_EXISTS
。
请求去重
对于网络 API
来说首选幂等的 API
,因为当发生网络异常时可以安全的重试。不过某些 API
无法轻易的实现幂等,例如创建一个资源,但是又需要避免重复的请求。这种情况下请求消息体应该包含一个唯一的 ID
,例如 UUID
使得服务端能够通过这个唯一的 ID
进行去重:
1 | // 服务端根据此唯一的 ID 进行去重 |
当服务端监测到重复的请求时,服务端应当返回之前成功的相同请求的结果给客户端,因为客户端大概率没有收到之前的返回结果。
枚举默认值
每一个枚举的定义都必须以0作为起始值,当枚举值未明确时应当使用该0值,同时 API
文档必须标注如何处理0值。
枚举0值应当命名为 ENUM_TYPE_UNSPECIFIED
。如果 API
有着通用的默认行为,则枚举值未定义时应当使用0值,否则0值应当被拒绝并返回 INVALID_ARGUMENT
错误。
枚举值示例:
1 | enum Isolation { |
在某些情况下可能使用某个惯用名表示枚举0值,例如 google.rpc.Code.OK
是错误码不存在时的默认值,它在语义上等价于 UNSPECIFIED
。
语法规则
在某些场景下,需要为特定的数据格式定义简单的语法,例如允许接受的文本输入。为了在各 API
间提供一致的开发体验,API
设计者必须使用如下的 Extended Backus-Naur Form (EBNF)
的变种来定义语法:
1 | Production = name "=" [ Expression ] ";" ; |
整数类型
设计 API
时应当避免使用无符号整型例如 uint32
和 fixed32
,因为某些重要的编程语言或者系统不能很好的支持无符号整型,例如 Java
,JavaScript
和 OpenAPI
,并且它们很大可能会造成整型溢出错误。另一个问题是不同的 API
可能将同一个值各自解析为不同的无符号整型或者带符号整型。
在某些场景下类型为带符号整型的字段值如果是负数则没有意义,例如大小,超时时间等等;API
设计者可能会用-1(并且只有-1)来表示特殊的含义,例如文件结束符(EOF
),无限的超时时间,无限的配额或者未知的年龄等等。这种用法必须明确的在接口文档中标注以避免迷惑。同时 API
设计者也应当标注当整型数值为0时的系统行为,如果它不是非常直白明了的话。
局部响应
在某些情况下,客户端可能只希望获取资源的部分属性。Google API
通过 FieldMask
来支持这一场景。
对于任意 Google API
的 REST
接口,客户端都可以传入额外的 $fields
参数来表明需要获取哪些字段:
1 | GET https://library.googleapis.com/v1/shelves?$fields=shelves.name |
资源视图
为了减少网络传输,可以允许客户端指定需要返回资源的某个视图而不是完整的资源数据,这需要在请求消息体中增加一个额外的参数,该参数要求:
- 应该是
enum
类型 - 必须命名为
view
枚举类型的每一个值都定义了应当返回资源的哪部分数据。具体返回哪部分数据由实现决定并且应当在文档中标注。
接口定义示例:
1 | package google.example.library.v1; |
客户端就可以通过如下的方式调用:
1 | GET https://library.googleapis.com/v1/shelves/shelf1/books?view=BASIC |
ETags
ETag
用于客户端进行条件请求,例如客户端获取了某个资源后将其缓存,下次再请求相同的资源时附带上之前服务端返回的 ETag
,如果服务端判断 ETag
没有发生变化则无需返回完整的资源实体。为了支持 ETag
,应当在资源定义时添加 etag
字段,同时其语义应当同 ETag
的常见用法保持一致。
ETag
支持强校验和弱校验,弱校验时 ETag
的值需要添加前缀 W/
。强校验模式下,如果两个资源有着相同的 ETag
则说明这两个资源的每个字节都是相同的,而且有着相同的额外字段(例如 Content-Type
)。这表示强校验模式下获取的多个资源局部响应数据可以组合成为一个完整的资源数据。
相反的,弱校验模式下两个资源有着相同的 ETag
并不能说明两个资源的每一个字节都相同,因此不适合用于缓存字节范围的请求响应。
强弱 ETag
示例:
1 | // 强 ETag,包括引号 |
需要注意的是,引号也是 ETag
的一部分,所以如果 ETag
在 JSON
中表示需要对引号进行转义:
1 | // 强 |
输出字段
一个资源的某些字段可能不允许客户端设置而只能由服务端返回,这些字段应当需要额外标注。
需要注意的是如果仅作为输出的字段在请求消息体中设置了,或者包含在了 google.protobuf.FieldMask
中,服务端也必须接受该请求而不是返回错误,只不过服务端在处理时需要忽略这些输出字段。之所以要这么做是因为客户端经常会复用某个接口返回的资源,将其作为另一个接口的输入,例如客户端可能会先请求获取一个 Book
资源,将其修改后再调用 UPDATE
方法。如果服务端对输出字段进行校验,则要求客户端进行额外的处理来删除这些输出字段。
接口示例如下:
1 | import "google/api/field_behavior.proto"; |
单例资源
当只有一个资源存在于某个父资源下(或服务,如果没有父资源的话)时,则可以使用单例资源。
标准方法的 Create
和 Delete
方法对单例资源无效,单例资源一般随着父资源的创建而创建,
随着父资源的删除而删除。单例资源必须通过标准方法的 Get
和 Update
方法来访问,以及其他适合的自定义方法。
例如,每一个 User
资源可以有一个单例的 Settings
资源:
1 | rpc GetSettings(GetSettingsRequest) returns (Settings) { |
流式半关闭
对于任何的双向或者客户端流式 API
,服务端应该依赖由 RPC
系统提供、客户端发起的半关闭来完成客户端流。没有必要额外的定义一个完成的消息。
任何在客户端发起半关闭前想要发送的消息都必须定义为请求消息体的一部分。
Domain-scoped 名称
domain-scoped
名称指的是添加了域名前缀的实体名称,用于避免不同服务的命名冲突。Google APIs
和 Kubernetes APIs
大量使用了 domain-scoped
名称:
Protobuf
的Any
类型:type.googleapis.com/google.protobuf.Any
Stackdriver
的指标类型:compute.googleapis.com/instance/cpu/utilization
- 标签的键:
cloud.googleapis.com/location
Kubernetes
的API
版本号:networking.k8s.io/v1
x-kubernetes-group-version-kind
的OpenAPI
扩展中的kind
字段
布尔值 vs. 枚举 vs. 字符串
设计 API
时有时候会遇到需要能够启用或者禁用某个功能的场景,从实现上说可以增加一个 bool
,enum
或者 string
类型的字段来控制,具体选择哪种类型可以遵循如下规则:
- 如果确定只有两种状态且不希望在未来扩展时使用
bool
,例如enable_tracing
或者enable_pretty_print
- 如果希望设计更为灵活但是又不希望改动太频繁时使用
enum
,一个评估的准则是一旦enum
的值确定了,那么一年内只会改动一次或者更低频,例如enum TlsVersion
或者enum HttpVersion
string
有着最大的灵活性,适用于可能会频繁修改的场景,其对应的值必须清晰的在文档中标注,例如:- Unicode regions 对应的
string region_code
- Unicode locales 对应的
string language_code
- Unicode regions 对应的
数据保留
对于某些服务而言,用户数据非常重要,如果用户数据不小心被软件 bug
或者人为错误删除,在缺少数据保留策略和撤销删除功能的情况下,可能对业务造成灾难性的影响。
一般而言,建议为 API
服务设置如下的数据保留策略:
- 对于用户的元数据,用户设置等其他重要的数据,设置30天的数据保留期。例如监控指标,项目的元数据和服务定义
- 对于大容量的用户数据,应该设置7天的数据保留期。例如对象存储和数据库表
- 对于临时的状态数据或者昂贵的存储数据,如果可行的话应该设置1天的数据保留期。例如
memcache
和Redis
内存中的数据
在数据保留期内,可以执行撤销删除的操作从而不会造成数据丢失。
大型传输载荷
网络 API
依赖分层的网络架构来传输数据,大多数的网络协议层对输入和输出的数据量设置了上限,一般而言,32 MB
是大多数系统中常用的大小上限。
如果某个 API
涉及的传输载荷超过 10 MB
,则需要选择合适的策略以确保易用性和未来的扩展的需求。对于 Google APIs
来说,建议使用流式传输或者媒体上传/下载的方式来处理大型载荷,在流式传输下,服务端能够以增量同步的方式处理大量数据,例如 Cloud Spanner API
。在媒体传输下,大量的数据流先流入到大型的存储系统中,例如 Google Cloud Storage
,然后服务端可以异步的从存储系统中读取数据并处理,例如 Google Drive API
。
可选的基本类型字段
Protocol Buffers v3
支持 optional
基本类型字段,在语义上等同于众多编程语言中的 nullable
类型,它可以用于区分空值和未设置的值。
在实践中开发人员难以正确的处理可选字段,大多数的 JSON HTTP
客户端类库,包括 Google API Client Libraries
,无法正确区分 proto3
的 int32
,google.protobuf.Int32Value
以及 optional int32
。如果存在一个方案更清晰而且也不需要可选的基本类型字段,则优先选择该方案。如果不使用可选的基本类型字段会造成复杂度上升或者含义不清晰,则选择可选的基本类型字段。但是不允许可选字段搭配包装类型使用。一般而言,从简洁和一致性考虑,API
设计者应当尽量选择基本类型字段,例如 int32
。
版本控制
Google APIs
借助版本控制来解决后向兼容问题。
所有的 Google API
接口都必须包含一个主版本号,这个主版本号会附加在 protobuf
包的最后,以及包含在 REST APIs
的 URI
的第一个部分中。如果 API
要引入一个与当前版本不兼容的变更,例如删除或者重命名某个字段,则必须增加主版本号,从而避免引用了当前版本的用户代码受到影响。
所有 API
的新主版本不允许依赖同 API
的前一个主版本。一个 API
本身可能会依赖其他 API
,这要求调用方知晓被依赖的 API
的版本稳定性风险。在这种情况下,一个稳定版本的 API
必须只依赖同样是稳定版本的其他 API
。
同一个 API
的不同版本在同一个客户端应用内必须能在一段合理的过渡时期内同时生效。这个过渡时期保障了客户端应用升级到新的 API
版本的平滑过渡。同样的,老版本的 API
也必须在废弃并最终停用之前留有足够的过渡时间。
对于会发布 alpha
或者 beta
版本的 API
来说,必须将 alpha
或者 beta
附加在主版本号之后,并且使用如下其一的策略:
- 基于渠道的版本控制(推荐)
- 基于发布的版本控制
- 基于可见性的版本控制
基于渠道的版本控制
stability channel
是在某个稳定性级别下长期进行更新的版本。每个主版本号下的每个稳定性级别最多只有一个版本。因此,在这个策略下,每个主版本最多只有三个可用的版本:alpha
,beta
,以及 stable
。
alpha
和 beta
版本必须将稳定性级别附加到主版本号后,而 stable
则不需要也不允许。例如,v1
可用作为 stable
版本的版本号,但是 v1beta
和 v1alpha
不是。类似的,v1beta
或者 v1alpha
可用作为对应的 beta
和 alpha
版本,但是 v1
不行。每个版本下会对新功能进行就地更新而不会修改版本号。
beta
版本的功能必须是 stable
版本的功能的超集,同时 alpha
版本的功能必须是 beta
版本的功能的超集。
对于任何版本的 API
来说,其中的元素(字段,消息体,RPC 方法等)都有可能被标记为废弃:
1 | // Represents a scroll. Books are preferred over scrolls. |
废弃的 API
功能不允许从 alpha
版本继续保留到 beta
版本,也不允许从 beta
版本保留到 stable
版本。也就是说某个功能不能在任何版本中预先废弃。
beta
版本的功能可以在废弃后经过合理的时间后删除,建议是180天。对于只存在于 alpha
版本的功能,不一定会标记为废弃,并且删除时也不会通知。
基于发布的版本控制
在该策略下,alpha
或者 beta
版本的功能在合并到 stable
版本之前只会在有限的时间内可用。因此,一个 API
在每个稳定性级别下可能有任意数量的版本发布。
基于渠道的版本控制和基于发布的版本控制都会就地更新
stable
版本。
alpha
和 beta
版本发布时需要在 alpha
或者 beta
之后附加一个递增版本号,例如 v1beta1
或者 v1alpha5
。API
应当在文档中记录这些版本的时间顺序。
每个 alpha
或者 beta
版本都有可能就地进行后向兼容的更新。对于 beta
版本来说,如果发布了后向不兼容的版本则应当修改 beta
后的版本号,然后发布新的版本。例如,如果当前版本是 v1beta1
,则新版本为 v1beta2
。
当 alpha
和 beta
版本中的功能合并到 stable
版本之后就可以终止 alpha
或者 beta
版本。alpha
版本可能会在任一时刻终止,但是 beta
版本在终止前应当给用户留有足够的过渡期,建议是180天。
基于可见性的版本控制
API 可见性 是 Google API
基础架构提供的一项高级功能。它允许 API
发布者将一个 API
对外暴露出多个不同的外部 API
视图,每个视图关联一个 API
可见性的标签,例如:
1 | import "google/api/visibility.proto"; |
可见性标签是一个区分大小写的字符串,可以绑定到任意 API
元素上。一般来说,可见性标签应当始终使用全大写字母。所有的 API
元素默认绑定 PUBLIC
的可见性标签,除非显式的声明了可见性。
每个可见性标签本质上是一个允许访问的列表,API
生产者需要授权给 API
消费者合适的可见性标签才能使用 API
。换句话说,可见性标签类似于 API
的 ACL
(Access Control List
)。
每个 API
元素可以绑定多个可见性标签,各可见性标签之间用逗号分割(例如 PREVIEW,TRUSTED_TESTER
)。多个可见性标签之间是逻辑或的关系,API
消费者只要授权了其中一个可见性标签就可以使用 API
。
一个 API
请求只能使用一个可见性标签,默认使用的是授权给当前 API
消费者的可见性标签。客户端可以显式的指定需要用哪个可见性标签:
1 | GET /v1/projects/my-project/topics HTTP/1.1 |
API
生产者可以借助可见性标签来实现版本控制,例如 INTERNAL
和 PREVIEW
。API
的新功能从 INTERNAL
可见性标签开始,然后升级到 PREVIEW
可见性标签。当功能稳定可用后,就删除所有的可见性标签,即等同于默认的可见性标签 PUBLIC
。
总体来说,API
的可见性比 API
版本号更容易实现增量的功能迭代,不过这要求比较成熟的 API
基础架构的支持。Google Cloud APIs
经常使用 API
可见性用于预览功能。
兼容性
这里的兼容性讨论的是对于 API
使用者的影响,API
生产者应当自身知晓为了实现兼容性需要哪方面的工作。
总的来说,API
的小版本更新或者补丁更新不应该对客户端造成兼容性问题。可能的不兼容问题包括:
- 源代码兼容性:针对1.0版本编写的代码升级到1.1版本后编译失败
- 二进制文件兼容性:针对1.0版本编译生成的二进制文件链接到1.1版本后运行失败
- 通信兼容性:针对1.0版本编写的应用程序无法和运行1.1版本的服务端通信
- 语义兼容性:针对1.0版本编写的应用程序升级到1.1版本后能够运行,但是存在不可预知的结果
从另一个角度来说,只要主版本号一致,运行着旧版本的客户端程序就能够和运行着新版本的服务端结合使用,并且客户端程序也能轻易的升级小版本。
后向兼容的修改
向 API 服务添加新的接口
从协议角度来看,添加新的接口始终是安全的。需要注意的是有可能新添加的接口名称已经被客户端代码占用了。如果当前新添加的接口与当前存在的接口完全不同,则基本不用担心;但是如果新添加的接口是当前某个存在的接口的简化版本,则有可能和客户端自定义实现的接口冲突。
向 API 接口添加新的方法
除非新添加的方法和客户端自动生成的代码中的某个方法冲突,否则这也是安全的。
例如当前已经存在了一个 GetFoo
方法,C#
的代码生成器会同时生成一个 GetFooAsync
的方法,如果此时再添加一个 GetFooAsync
方法,则会造成冲突。
向已有的方法添加 HTTP 绑定
假设绑定 HTTP
没有任何歧义,那么让服务端响应之前拒绝的 URL
就是安全的。这个操作可能在将某个已有的操作映射到某个新资源时发生。
向请求消息体添加新的字段
只要服务端在新版本的代码中处理未传入的新字段的逻辑和老版本代码中的逻辑一致,那么添加新的字段就是安全的。
一个最可能出错的场景是添加分页相关的字段:如果 v1.0
版本的代码中没有分页功能,那么也不能将分页功能添加到 v1.1
版本中,除非 page_size
的默认值是无限大(但这通常不是个好的设计)。否则的话依赖了 v1.0
版本的客户端期望一次请求获取所有的数据,但实际上可能只获取了第一页的数据。
向响应消息体添加新的字段
对于非资源类的响应消息体来说(例如 ListBooksResponse
)添加一个字段都不会造成后向兼容性问题,只要新添加的字段不会影响其他字段的行为即可。消息体中之前暴露的字段应当继续以相同的语义保留,即使可能存在冗余。
例如,1.0版本的响应消息体中有一个字段是 contained_duplicates
表示返回的结果存在重复值并已经进行了去重。在1.1版本中新增了 duplicate_count
字段表示重复的数据数量,虽然原有的 contained_duplicates
已经冗余了但是该字段也必须保留。
向枚举添加新值
如果枚举是在请求消息体中使用,那么向枚举添加新值是安全的。因为客户端并不关心它们用不到的值。
对于在资源消息体或者响应消息体中的枚举,默认的假设是客户端需要能够处理未知的枚举值。不过,API
生产者应当知晓客户端如何正确的处理新的枚举值不是一件简单的事,因此必须在文档中标注如果客户端遇到未知的枚举值时期望的行为是什么。
添加新的输出字段
如果一个字段只可能会由服务端设置并仅作为输出使用,那么添加这个字段也是安全的。服务端可能会校验客户端发送的消息体中的字段,但是如果新添加的输出字段在请求消息体中不存在,服务端不允许抛出异常。
后向不兼容的修改
移除或者重命名服务,字段,方法或者枚举值
一般来说,移除或者重命名某个客户端代码可能引用的内容都是一次后向不兼容的修改,必须升级主版本号才能更新。对于某些编程语言来说如果引用了旧的名称则会造成编译问题(例如 C#
和 Java
)或者对于另一些编程语言来说造成运行时异常或者数据丢失。
更改 HTTP 映射
这里的更改指的是删除然后添加。例如,假设你想将某个已经存在的方法的 HTTP
映射改为 PATCH
,而目前暴露的 HTTP
方法是 PUT
,你可以添加一个新的 HTTP
映射,但是不能删除原有的 HTTP
映射。
更改字段类型
即使更改后的字段类型和当前的传输格式兼容,也可能造成客户端生成的代码不兼容,因此也必须升级主版本号才能更新。对于静态编译型编程语言来说,这很容易引入编译问题。
更改资源的命名格式
不允许修改资源的名称,这也意味着资源集合的名称也不允许修改。
如果客户端能够在 v2.0
版本中访问 v1.0
版本中创建的资源(或者反过来),那么该资源在两个版本中就应当使用相同的资源名称。
另外,有效的资源名称集也不能修改,因为:
- 如果集合变小,那么之前成功的请求就有可能失败
- 如果集合变大,那么客户端基于之前关于资源名称的假设可能失效。因为客户端很可能会根据资源名称所允许的字符和长度将其存储在其他地方,以及构建自己的资源名称验证规则
更改现有请求的可见行为
客户端经常会依赖 API
的行为和语义,即使这些行为没有明确的表示支持或者在文档中说明。因此,在大多数情况下更改 API
的行为会造成后向不兼容问题。如果你的 API
的行为不是非常的隐秘,你都应该假设用户已经识别出 API
的行为并依赖它。
因此,加密分页功能中的页码信息就很有必要(即使该数据没有什么意义),从而防止用户自行创建页码信息,然后当页码行为更改时遭遇后向不兼容问题。
更改 HTTP 定义中的 URL 格式
除了资源名称之外,还有两个关于 URL
格式的修改:
- 自定义方法的名称:虽然自定义方法名称不是资源名称的一部分,但依然是
REST
请求路径的一部分,虽然修改HTTP
的自定义方法名称不会破坏gRPC
客户端,但依然要假设存在REST
客户端的用户 - 资源参数名称:例如从
v1/shelves/{shelf}/books/{book}
修改为v1/shelves/{shelf_id}/books/{book_id}
不会影响资源名称,但是会影响客户端自动生成的代码
向资源消息体添加读/写字段
客户端经常会执行先读,然后修改,最后写入的一整套操作,大多数情况下如果某个字段客户端用不到就不会给它赋值。虽然服务端可以采取缺失值的字段就不执行写入的措施,但不适用于基本类型的字段(包括 string
和 bytes
),因为基本类型默认值的存在造成无法区分出是客户端主动设置 int32
类型的字段值为0还是没有设置值从而使用默认值0。
总结
虽然 Google
的这篇 API
设计主要是面向资源的设计,但同时也针对其不足提出了改进的方案。不管是 RESTful
还是非 RESTful
的接口设计,都只是一种规范,有各自适合的场景没有孰优孰劣,统一的规范胜过生搬硬套。
参考
AWS EC2 监控内存使用
AWS
EC2
的监控页面默认没有显示内存使用率,需要搭配 CloudWatch
配置使用。
由于需要在 EC2
上安装 CloudWatch agent
来上报监控数据到 CloudWatch
,所以需要先为 EC2
配置 IAM
角色来授予需要的权限。创建 IAM
角色时,在第一步的 Trusted entity type
选择 AWS service
,Use case
选择 EC2
;在第二步的 Permissions policies
添加 CloudWatchAgentServerPolicy
即可。更多细节可参考 Create IAM roles and users for use with CloudWatch agent。
接着,在 Download and configure the CloudWatch agent using the command line 中根据实际 EC2
的操作系统下载和安装 CloudWatch agent
,这里以 ARM64
的 Ubuntu
系统为例:
1 | wget https://s3.amazonaws.com/amazoncloudwatch-agent/ubuntu/arm64/latest/amazon-cloudwatch-agent.deb |
然后,为 CloudWatch agent
创建一个配置文件,例如 cloudwatch.json
,写入如下内容:
1 | { |
这表示每隔60秒收集一次内存使用率,接着启动 CloudWatch agent
:
1 | sudo amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -c file:$HOME/cloudwatch.json -s |
可以通过 amazon-cloudwatch-agent-ctl -a status
来查看 CloudWatch agent
的状态:
1 | { |
此时 CloudWatch agent
的状态为运行中。
如果一切正常,那么在 AWS
控制台中 CloudWatch
的 All metrics
下会多出一项 CWAgent
(如果原来没有添加过的话):
点击进入后选择相应的 EC2
,点击 Add to graph
:
在当前页面上方就会显示对应的内存使用率的监控:
之后也可以创建一个 Dashboard
,将这个监控加入到自定义的 Dashboard
中。
如果在 AWS
控制台没有看到 CWAgent
项目,那么可以查看 EC2
上 CloudWatch agent
的日志是否有异常,日志保存在 /opt/aws/amazon-cloudwatch-agent/logs/amazon-cloudwatch-agent.log
。例如,如果忘记为 EC2
配置 IAM
角色,同时 EC2
上又没有其他的权限访问信息,CloudWatch agent
就无法上报监控数据,会提示如下类似的异常:
1 | 2022-10-09T13:27:36Z E! WriteToCloudWatch failure, err: NoCredentialProviders: no valid providers in chain |
最后,如果想要添加更多的监控指标,可以参考 Metrics collected by the CloudWatch agent 添加相应的指标。
参考:
AWS EC2 挂载磁盘
挂载磁盘
在创建 AWS
的 EC2
实例时如果添加了额外的磁盘则需要手动挂载到系统中。
首先运行 lsblk
来查看可用的块设备:
1 | NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS |
其中的 nvme1n1
是本次新添加的磁盘,目前还未挂载到系统中,而 nvme0n1
则是根设备并且有两个分区。
lsblk
的输出结果会移除设备路径中的/dev/
前缀,所以设备nvme1n1
的完整路径为/dev/nvme1n1
。
然后,我们需要在 nvme1n1
之上创建文件系统才能使用,执行 sudo file -s /dev/nvme1n1
显示 nvme1n1
还没有文件系统:
1 | /dev/nvme1n1: data |
而如果我们查看 sudo file -s /dev/nvme0n1
则会显示:
1 | /dev/nvme0n1: DOS/MBR boot sector; partition 1 : ID=0xee, start-CHS (0x0,0,2), end-CHS (0x3ff,255,63), startsector 1, 16777215 sectors, extended partition table (last) |
执行 sudo mkfs -t xfs /dev/nvme1n1
来为 nvme1n1
创建文件系统,其中 xfs
表示文件系统的类型:
1 | meta-data=/dev/nvme1n1 isize=512 agcount=8, agsize=262144 blks |
接着,我们就可以创建一个文件夹用来挂载磁盘,例如 sudo mkdir /data
。最后将 /dev/nvme1n1
挂载到 /data
上:
1 | sudo mount /dev/nvme1n1 /data |
此时如果查看 df -h
就会包含 /dev/nvme1n1
:
1 | Filesystem Size Used Avail Use% Mounted on |
系统启动自动挂载磁盘
当前的磁盘挂载信息会在系统启动后丢失,如果希望系统启动后自动挂载磁盘则需要向 /etc/fstab
中添加一条记录。
安全起见先备份下 /etc/fstab
:
1 | sudo cp /etc/fstab /etc/fstab.orig |
然后运行 sudo blkid
来查看设备 /dev/nvme1n1
的 UUID
:
1 | /dev/nvme0n1p1: LABEL="cloudimg-rootfs" UUID="15ea47e1-ef7d-4928-9dbe-ffaf0e743653" BLOCK_SIZE="4096" TYPE="ext4" PARTUUID="1957f80e-a338-441c-a0e0-ed1575eefda3" |
最后向 /etc/fstab
添加一条记录:
1 | UUID=aa81c000-325c-40b7-ba4c-598ec2c824e0 /data xfs defaults,nofail 0 2 |
可以通过先取消挂载 /data
即 sudo umount /data
然后再执行 sudo mount -a
来验证自动挂载是否生效。
参考
创建 EKS 集群
介绍
EKS
(Amazon Elastic Kubernetes Service
)是 AWS
提供的 Kubernetes
服务,它能大大减轻创建和维护 Kubernetes
集群的负担。
创建 EKS 集群
有两种方式来创建 EKS
集群,一种是使用本地的 eksctl
程序;另一种是通过 AWS
的管理后台(AWS Management Console
),这里选择通过 AWS
的管理后台来创建 EKS
集群。
创建 Cluster service role
创建 EKS
集群时需要绑定一个 IAM
角色,因为 Kubernetes
的 control plane
需要管理集群内的资源,所以需要有相应的操作权限。
首先进入 IAM 控制台,选择左侧 Access management
下的 Roles
,点击 Create role
。在 Trusted entity type
下选择 AWS service
,然后在 Use cases for other AWS services
下选择 EKS
,接着选择 EKS - Cluster
并点击 Next
。在 Add permissions
这步直接点击 Next
。在最后一步设置所创建的角色的名字,如 eksClusterRole
,最后点击 Create role
创建角色。
创建集群
我们通过 AWS
管理后台中的 Amazon Elastic Kubernetes Service
界面来创建集群,第一步的 Configure cluster
主要设置集群的名称,如 my-cluster
,以及绑定在之前步骤中所创建的 Cluster service role
。第二步的 Specify networking
这里基本都保持默认,只是将 Cluster endpoint access
设置为 Public and private
。第三步的 Configure logging
可以暂时不开启日志监控。最后在第四步的 Review and create
点击 Create
创建集群。
创建 Node group
当集群的状态变为 Active
后就表示集群创建成功,不过此时集群中还没有任何 Node
,所以系统级别的 Pod
还无法正常工作,比如在集群详情的 Resources
下查看某个 coredns
的 Pod
会显示 FailedScheduling
,因为 no nodes available to schedule pods
。
我们需要创建 Node group
来为系统添加可用的 Node
。
创建 Node IAM role
在创建 Node group
前,需要创建一个 Node IAM role
。因为集群中的 Node
内部会运行着一个叫做 kubelet
的程序,它负责和集群的 control plane
进行通信,例如将当前 Node
注册到集群中,而某些操作需要调用 AWS
的接口,所以和 Cluster service role
类似,也需要绑定相应的权限。
这里同样也是通过 IAM 控制台 来创建角色,在 Trusted entity type
下选择 AWS service
,在 Use case
下选择 EC2
,然后点击 Next
。在第二步的 Add permissions
需要添加 AmazonEKSWorkerNodePolicy
,AmazonEC2ContainerRegistryReadOnly
和 AmazonEKS_CNI_Policy
三个权限,虽然文档中说不建议将 AmazonEKS_CNI_Policy
权限添加到 Node IAM role
上,不过这里作为示例教程将三个权限都绑定在了 Node IAM role
上。最后也是点击 Create role
创建角色。
创建 Node group
在集群详情的 Compute
下点击 Add node group
来创建 Node group
,在第一步 Configure node group
中设置 node group
的名称及绑定在之前步骤中所创建的 Node IAM role
。在第二步 Set compute and scaling configuration
里配置节点的类型和数量等信息,作为教程都采用默认配置。第三步 Specify networking
同样采用默认配置。最后在第四步的 Review and create
点击 Create
完成创建。
最后当所创建的 Node group
的状态变为 Active
以及该 Node group
下的 Node
的状态变为 Ready
时说明节点创建成功。此时再查看集群详情下 Resources
的 coredns
的 Pod
已成功分配了 Node
运行。
连接 EKS 集群
日常需要通过 kubectl
管理集群,所以需要先在本地配置访问 EKS
集群的权限。kubectl
本质上是和 Kubernetes API server
打交道,而创建集群时 Cluster endpoint access
部分选择的是 Public and private
,所以在这个场景下能够从公网管理 EKS
集群。
首先需要安装 AWS CLI 和 kubectl。然后在本地通过 aws configure
来设置 AWS Access Key ID
和 AWS Secret Access Key
。根据 Enabling IAM user and role access to your cluster 的描述,创建集群的账户会自动授予集群的 system:masters
权限,本文是通过 AWS
的管理后台创建集群,当前登录的账户为 root
,所以 aws configure
需要设置为 root
的 AWS Access Key ID
和 AWS Secret Access Key
:
When you create an Amazon EKS cluster, the AWS Identity and Access Management (IAM) entity user or role, such as a federated user that creates the cluster, is automatically granted system:masters permissions in the cluster’s role-based access control (RBAC) configuration in the Amazon EKS control plane.
一般公司生产环境中的 AWS
是不会直接使用 root
账户登录的,而是创建 IAM
用户,由于这里是个人的 AWS
账户所以直接使用了 root
,反之就需要使用 IAM
用户的 AWS Access Key ID
和 AWS Secret Access Key
。设置完成之后可以通过 aws sts get-caller-identity
来验证当前用户是否设置正确:
1 | { |
然后运行 aws eks update-kubeconfig --region us-west-2 --name my-cluster
来更新本地的 kubeconfig
,其中 us-west-2
需要修改为实际的 AWS Region
,my-cluster
需要修改为实际的集群名称。最后就可以通过 kubectl get all
来验证能否访问集群,如果没有问题就会输出如下类似内容:
1 | NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE |
设置其他用户的集群访问权限
创建集群的账户可能权限较高,所以需要单独给某些账户开通集群的访问权限。可以通过 kubectl describe -n kube-system configmap/aws-auth
查看当前的权限分配情况:
1 | Name: aws-auth |
假设我们需要授予某个 IAM
用户 eks
system:masters
的角色,首先运行 kubectl edit -n kube-system configmap/aws-auth
:
1 | # Please edit the object below. Lines beginning with a '#' will be ignored, |
这里在 data
下新增了 mapUsers
,授予用户 eks
system:masters
的角色:
1 | mapUsers: | |
保存后可以通过 kubectl describe configmap -n kube-system aws-auth
验证改动是否生效。然后下载 aws-auth-cm.yaml
:
1 | curl -o aws-auth-cm.yaml https://s3.us-west-2.amazonaws.com/amazon-eks/cloudformation/2020-10-29/aws-auth-cm.yaml |
将其中的 <ARN of instance role (not instance profile)>
替换为之前创建的 Node IAM role
,然后执行 kubectl apply -f aws-auth-cm.yaml
应用修改,执行 kubectl get nodes --watch
观察是否所有的节点的状态都变为了 Ready
。
接着删除本地的 ~/.kube/config
来验证权限是否生效。重新运行 aws configure
来设置某个 IAM
用户的信息,因为我们要重新执行 aws eks update-kubeconfig --region us-west-2 --name my-cluster
来生成新的 ~/.kube/config
,这里要求当前 IAM
用户拥有 DescribeCluster
的权限,这个权限是 AWS
层面的资源访问权限,而不是 EKS
集群的权限,添加权限后可能需要等待几分钟才会生效。当重新生成了 ~/.kube/config
文件之后,就可以继续通过 kubectl get all
验证访问权限是否生效。
参考
Hello Minikube - Apple M1 Max connection reset
在 Apple M1 Max
处理器上按照 Hello Minikube 进行 minikube
的入门教程,不过最后通过本地链接访问的时候出现了 connection reset
。按照 这里 的描述需要将镜像由 echoserver:1.4
换成适用于 Apple M1 Max
的 echoserver-arm:1.8
,即:
1 | kubectl create deployment hello-arm --image=registry.k8s.io/echoserver-arm:1.8 |
不过帖子中也有人提到换了镜像之后依然无效,所以也不一定对所有人有用。
参考:
Buddy Memory Allocation
介绍
Buddy Memory Allocation
是内存分配算法的一种,它假定内存的大小为2N(N
为整数),并且总是以2的幂次方为单位分配或者释放内存。
算法
假设某个线程需要申请 m
字节内存,Buddy Memory Allocation
会先在当前所有的空闲空间中找到最小的空间满足2k≥m,如果2k的一半依然大于等于 m
,说明当前分配的空间过大,则继续将2k对半分(分裂后的这两块内存区域就成为了互为兄弟关系(buddies
)),不断重复上述操作,直到找到最小的 p
(p≤k)满足2p−1<m≤2p。
下图描述了从16字节中分配3字节的过程(假设系统总共只有16字节内存):
- 初始状态整个内存只有16字节,是可分配的最小空间;不过由于16字节的一半大于3字节,所以将16字节拆分为两个8字节
- 同理一个8字节的一半依然大于3字节,继续将其中一个8字节拆分为两个4字节
- 4字节的一半比3字节小,所以4字节就是可分配的最小内存空间
当某个线程需要释放2k的内存时,Buddy Memory Allocation
会尝试将这个内存空间及其相邻的兄弟空间一起合并得到一个2k+1大小的空间,然后一直重复此操作,直到某块内存空间无法和其兄弟空间合并,无法合并的情况有三种:
- 当前分配的内存空间大小为整个内存空间的大小,所以也就没有兄弟空间
- 兄弟空间已全部分配
- 兄弟空间已局部分配
下图描述了从16字节中释放3字节的过程(假设系统总共只有16字节内存):
- 当前系统分配了一个2字节的空间和一个4字节的空间
- 此时需要回收被占用的2字节,由于它的兄弟空间没有被占用,所以两个2字节的空间合并为一个4字节的空间
- 合并后的4字节的空间的兄弟空间同样没有被占用,两个4字节的空间继续合并为1个8字节的空间
- 合并后的8字节的空间的兄弟空间存在部分占用,无法继续合并
实现
内存
首先定义一个 Memory
类来表示内存,其内部使用一个 byte
数组来存储数据,数组的索引就是内存地址:
1 | public class Memory { |
同时,Memory
类还支持 bool
和 int32
类型的数据读写,从实现的简化考虑,bool
值的读写以一个 byte
为单位;而 int32
的读写以4个 byte
为单位:
1 | // 在给定的地址设置布尔值,占据一字节 |
Block
定义 Block
表示系统所分配的内存块,其中 address
表示该 Block
的起始内存地址,同时 Block
借助 Memory
对内存实现读写操作:
1 | public class Block { |
下图展示了一个 Block
在内存中的布局:
一个 Block
除了包含用户数据外还需要保存元数据,所以每个 Block
占据的内存会大于用户实际申请的内存;元数据中的第一个字节表示当前内存块是否被使用;第2到5字节表示 sizeClass
,用来计算当前内存块所占据的内存的大小,即2sizeClass;第6到9字节表示前一个空闲内存块的地址;第10到13字节表示后一个空闲内存块的地址;从第14字节开始就是用户数据。当然,这只是一种很粗犷的布局方式,实际应用中的布局必然比这个精炼。
这里需要前一个/后一个空闲内存块的地址是因为将相同大小的内存块通过双向链表的方式串联在一起,从而能快速找到以及删除某个指定大小的内存块。因为 Buddy Memory Allocation
始终以2k大小分配内存,假设系统的最大内存为2N,则可以建立 N
个双向链表,每个双向链表表示当前大小下可用的内存块,如下图所示:
Block
通过 Memory
类提供的 bool
,int32
数据的读写功能来实现对元数据的读写:
1 | // 将 block 标记为已使用 |
BlockList
BlockList
表示一个双向链表,用于存储某个 sizeClass
下的所有空闲内存块,为了实现方便,内部使用了一个哨兵头节点来作为双向链表的头节点,新节点的插入采用头插法的方式:
1 | package buddy; |
由于需要通过哨兵头节点访问下一个可用的内存块,所以每个哨兵头节点就需要知道下一个 Block
的内存起始地址,因此同样需要将哨兵头节点的信息保存在内存中,对于内存大小为2N的系统来说,一共需要保存 N
个哨兵头节点的信息,这里将内存分为两部分,前一部分保存所有的哨兵头节点,后一部分保存所有的 Block
:
因此第一个 Block
的内存起始位置也就等于所有哨兵节点的大小之和。
内存管理
初始化
定义 Allocator
负责内存的分配和回收,本质上是对 Block
的管理,即 Block
的分裂和合并:
1 | public class Allocator { |
在这个例子中,我们假设系统最大能支持的内存大小为216个字节,由于哨兵节点也需要占用一部分内存,所以在构造函数中初始化 Memory
的大小为所有哨兵节点占用的内存大小加上 216 个字节。同时,系统可分配的 Block
的大小分别为21,22,…,215,216,对应需要初始化16个双向链表,这里简单的使用数组来保存这16个双向链表,并初始化对应哨兵头节点的内存起始地址。同时,整个系统在初始状态只有一个 Block
,大小为216。
内存分配
如前面所述,内存分配的第一步是找到满足用户内存需求的最小的 Block
,然后如果 Block
过大则继续将 Block
进行分裂:
1 | public int alloc(int size) { |
内存回收
应用程序要求释放内存时,提交的是用户数据的起始地址,需要先将其转为 Block
的起始地址(减去 Block
元数据的占用空间大小即可),然后尝试将 Block
和其兄弟合并,并将合并后的 Block
加入到空闲列表中:
1 | public void free(int userAddress) { |
这里有个关键的问题在于如何根据 block
的地址知道其兄弟 block
的地址?因为一个 block
会被分为左兄弟和右兄弟两个内存块,如果当前 block
是左兄弟,则右兄弟的地址为 block.getAddress() + 1 << sizeClass
,如果当前 block
是右兄弟,则左兄弟的地址为 block.getAddress() - 1 << sizeClass
。然而由于缺失位置信息我们并不能知道一个 block
是左兄弟还是右兄弟。
原作者在这里巧妙的在不引入额外的元数据的情况下解决了这个问题。首先,对于某个 sizeClass
为 k
的内存块来说,它的起始地址一定是C2k,其中 C
为整数。这里使用数学归纳法来证明,假设系统内存最多支持2N个字节,则初始状态下整个系统只有一个内存块,k
就等于 N
,该内存块的起始地址为0,满足C2k,取 C = 0
即可。假设某个 sizeClass
为 k
的内存块的起始地址满足C2k,则需要进一步证明分裂后的两个内存块的起始地址为C′2k−1。而分裂后的内存块的起始地址分别为C2k和C2k+2k−1,又C2k=(2C)2k−1,C2k+2k−1=(2C+1)2k−1,证明完毕。同时,由这些公式可以发现,对于左兄弟内存块来说,C
是偶数,而对于右兄弟内存块来说 C
是奇数。更进一步来说,左右兄弟内存块的地址差异仅在于从低位往高位数的第 k + 1
位不同。
因此,根据某个内存块的地址推算出兄弟内存块的地址只需要将当前内存块的地址从低位往高位数第 k + 1
位反转即可。这种涉及反转比特位的操作就可以使用异或运算,我们可以将内存块的地址和 1 << sizeClass
(也就是2k)进行异或运算,得到的地址就是对应兄弟内存块的地址。
另外,由于哨兵头节点的存在,Memory
内部的数组大小不是严格的2k,在计算兄弟内存块的地址时,可以先将当前内存块的地址减去哨兵头节点的大小之和,计算出兄弟内存块的地址之后,再加回偏移量:
1 | private Block getBuddy(Block block, int sizeClass) { |
总结
以上仅作为 Buddy Memory Allocation
算法的示例,不具有实际应用意义,例如完全没有考虑线程安全。完整的代码可参考原作者的 代码 及 Java
版本的 buddy-memory-allocation。
参考
A Template Engine - 简易模板引擎实现
本文内容主要来源于 A Template Engine。
支持的语法
首先来看一下这个模板引擎所支持的语法。
变量
使用 {{ variable }}
来表示变量,例如:
1 | <p>Welcome, {{user_name}}!</p> |
如果 user_name
是 Tom
,则最后渲染的结果为:
1 | <p>Welcome, Tom!</p> |
对象属性和方法
除了字面量外,模板引擎的变量还支持复杂对象,可以通过点操作符来访问对象的属性或方法,例如:
1 | <p>The price is: {{product.price}}, with a {{product.discount}}% discount.</p> |
注意如果访问的是对象的方法,则不需要在方法名后添加 ()
,模板引擎会自动解析并调用方法。
同时,还可以使用管道操作符来链式调用过滤器,从而改变所渲染的变量值,例如:
1 | <p>Short name: {{story.subject|slugify|lower}}</p> |
条件判断
使用 {% if condition %} body {% endif %}
来表示条件判断,例如:
1 | {% if user.is_logged_in %} |
循环
使用 {% for item in list %} body {% endfor %}
来表示循环,例如:
1 | <p>Products:</p> |
注释
使用 `` 来表示注释,例如:
1 | {# This is the best template ever! #} |
实现
一般来说,一个模板引擎主要做两件事:模板解析和渲染。这里要实现的模板引擎的渲染包括:
- 管理动态数据
- 执行逻辑语句,例如
if
,for
- 实现点操作符访问和过滤器执行
类似于编程语言的实现,模板引擎的解析也可以分为解释型和编译型两种。对于解释型来说,模板解析阶段需要生成某个特定的数据结构,然后在渲染阶段遍历该数据结构并执行所遇到的每一条指令;而对于编译型来说,模板解析阶段直接生成可执行代码,而渲染阶段则大大简化,直接执行代码即可。
本文描述的模板引擎采用编译型的方式,原文的作者将模板编译为了 Python
代码,这里为了进一步加深理解,实现了 .NET Core
版本的简单编译。
编译为 C# 代码
在介绍模板引擎实现之前,先来看一下模板引擎编译出的 C#
代码示例,对于如下的模板:
1 | <p>Welcome, {{userName}}!</p> |
模板引擎会生成类似于下面的代码:
1 | public string Render(Dictionary<string, object> Context Context, Func<object, string[], object> ResolveDots) |
其中 Context
表示全局上下文,用于获取渲染需要的动态数据,例如例子中的 userName
,Render
方法会先从 Context
中提取出模板中所有需要的变量;ResolveDots
是一个函数指针,用于执行点操作符调用;而变量的值都会通过 Convert.ToString
转为字符串。
模板引擎的最终产物是一个字符串,所以在 Render
中先使用一个 List
保存每一行的渲染结果,最后再将 List
转换为字符串。
.NET
编译器提供了 Microsoft.CodeAnalysis.CSharp.Scripting
包来将某段字符串当做 C#
代码执行,所以最终模板引擎生成的代码将通过如下方式执行:
1 | var code = "some code"; |
模板引擎编写
Template
Template
是整个模板引擎的核心类,它首先通过模板和全局上下文初始化一个实例,然后调用 Render
方法来渲染模板:
1 | var context = new Dictionary<string, object>() |
这里将 text
传入 Template
的构造函数后,会在构造函数中完成模板解析,后续的 Render
调用都不需要再执行模板解析。
CodeBuilder
在介绍 Template
的实现之前,需要先了解下 CodeBuilder
,CodeBuilder
用于辅助生成 C#
代码,Template
通过 CodeBuilder
添加代码行,以及管理缩进(原文的作者使用 Python
作为编译的目标语言所以这里需要维护正确的缩进,C#
则不需要),并最终通过 CodeBuilder
得到可执行代码。
CodeBuilder
内部维护了一个类型为 List<object>
的变量 Codes
来表示代码行,这里的 List
容器类型不是字符串是因为 CodeBuilder
间可以嵌套,一个 CodeBuilder
可以作为一个完整的逻辑单元添加到另一个 CodeBuilder
中,并最终通过自定义的 ToString
方法来生成可执行代码:
1 | public class CodeBuilder |
CodeBuilder
的 AddLine
方法非常简单,即根据缩进层级补齐空格后添加一行代码(这里 C#
版本保留了 Python
版本缩进的功能):
1 | public void AddLine(string line) |
Indent
和 Dedent
用于管理 Python
代码的缩进层级:
1 | public void Indent() |
AddSection
用于创建一个新的 CodeBuilder
对象,并将其添加到当前 CodeBuilder
的代码行中,后续对子 CodeBuilder
的修改都会反应到父 CodeBuilder
中:
1 | public CodeBuilder AddSection() |
最后重写了 ToString()
方法来生成可执行代码:
1 | public override string ToString() |
Template 实现
编译
模板引擎的模板解析阶段发生在 Template
的构造函数中:
1 | public Template(string text, Dictionary<string, object> context) |
Python
版本的代码支持多个 context
,会由构造函数统一合并为一个上下文对象,这里只简单实现仅支持一个 context
;AllVariables
用于记录模板 text
中需要用到的变量名,例如 userName
,然后在代码生成阶段就可以遍历 AllVariables
并通过 var someName = Context[someName];
生成局部变量;不过由于模板中的变量可能还会有循环语句用到的临时变量,这些变量会记录到 LoopVariables
中,最终代码生成阶段用到的变量为 AllVariables - LoopVariables
。
接着我们再来看 Initialize
方法:
1 | private void Initialize(string text) |
Initialize
首先会通过 CodeBuilder
分配一个 List
保存所有的代码行,然后新建一个子 CodeBuilder
来保存所有的局部变量,接着解析 text
,在完成 text
的解析后就能知道模板中使用了哪些变量,从而根据 AllVariables - LoopVariables
生成局部变量,最后将所有的代码行转成字符串。
同时,原文作者在这里有一个优化,相比于在生成的代码中不断的调用 result.Add(xxx)
,从性能上考虑可以将多个操作合并为一个即 result.AddRange(new List<string> { xxx })
,从而引出了辅助变量 buffered
和辅助方法 FlushOutput
:
1 | var buffered = new List<string>(); |
在解析 text
时,并不会处理完一个 token
就执行一次 this.CodeBuilder.AddLine
,而是将多个 token
的处理结果批量的追加到最终的可执行代码中。
接着,再回到 Initialize
方法中,由于模板中 if
,for
可能存在嵌套,为了正确处理嵌套语句,这里引入一个栈 var operationStack = new Stack<string>();
来处理嵌套关系。例如,假设模板中存在 {% if xxx %} {% if xxx %} {% endif %} {% endif %}
,每次遇到 if
时则执行入栈,遇到 endif
时则执行出栈,如果出栈时栈为空则说明 if
语句不完整,并抛出语法错误。
那么,如何解析 text
呢?这里使用正则表达式来将 text
分割为 token
:
1 | private static Regex tokenPattern = new Regex("(?s)({{.*?}}|{%.*?%}|{#.*?#})", RegexOptions.Compiled); |
其中正则表达式中的 (?s)
使得 .
能够匹配换行符。
例如对于模板:
1 | <ol>{% for number in numbers %}<li>{{ number }}</li>{% endfor %}</ol> |
分割后的 tokens
为:
1 | [ |
然后我们就可以遍历 tokens
处理了,每种 token
对应一种策略,如果是注释,则忽略:
1 | if (token.StartsWith("{#")) |
如果是变量,则解析变量的表达式(表达式解析会在后面介绍)的值,然后再将其转为字符串:
1 | else if (token.StartsWith("{{")) |
而如果是 `