好 我们接下来讲Go的API设计
实际上API设计我们过去的一些经验
怎么讲就是踩了很多坑
举个最典型的例子
大家知道HTTP是有状态码的
那么这个状态码
我见过绝大多数的人都是返回200
然后把一些业务的错误再放到JSON的code里面
JSON比方说你定了一个code message data
那么大家会把这种业务错误都放到code里面
那么导致于什么呢
最终看上去监控或者说从SOA统计里面
永远都是200都是HTTP状态ok的
都是一个正确的
它无法把HTTP其他的状态把它使用起来
那么实际上HTTP状态码是有很多意义的
比方说500
对吧
比方说503服务不可用
那么这些都能被我们的像一些黑盒的监控
它去来ping你的时候
或者说通过一些Nginx的日志的一些监控
它都能知道你的服务这块在报错
如果都是200的话
你把一些内部错误吞掉
然后放到JSON里面
实际上从外围的监控设施或者从上游的设施
它完全不知道你服务的状态
以至于你这种服务的监控只能靠你自己暴露指标
我才能知道
那么极端情况下不是特别好
我觉得这种不是特别好
其实很多人都会犯类似这种错误
包括错误码
什么定义一个全局错误码
然后避免部门之间冲突
我们到底应该去怎么解决这些问题
对 我们来看一下我们在API设计上的一些工程化实践
首先先看一下我们API设计里面比较典型的rpc框架
我们最终使用的是gRPC的一个框架
那么关于gRPC大家可以直接去gRPC的官网
先简单的了解一下gRPC框架的一个使用
gRPC它既可以作为内网的rpc
它同时也可以作为外网的公网的
类似于像HTTP restful这种接口一样去使用
它都是非常方便的
那么如果你能够做到统一rpc框架
内网外网全部统一
真的 是我认为是非常好的
不然你要做很多很多
什么内网的rpc转换到外网的HTTP restful这种方式
其实你要做很多这种代码的一些转化
以至于对外和对内的服务定义是有区别的
这个不是特别好
所以我们在大概17年的时候
当时就驱动了内部的一个rpc框架统一
这个事情是越早做是越好的
我们为什么会选择gRPC
几个考虑我们
先看一下gRPC设计的一些理念
第一是多语言
我们看一下这个图里面
一个C++的服务既可以被Ruby来调用
也可以被Android的
Android的Java可能来使用
所以它是语言中立的
支持多种语言
另外它可以支持轻量级和高性能
比方说我们可以直接使用类似像Protocol Buffer
或者是JSON
甚至是xml来做它的序列化
我们知道Protocol Buffer相对来说性能也是非常不错的
所以说用Protocol Buffer的话来做序列化
就是它很轻量并且是高性能的
第三个是可插拔
很多组件在gRPC内部
它支持很多一些middleware或者叫intercept
你可以自己去自定义
甚至说像它的负载均衡的balance接口
或者是
它的reserve服务发现接口
你都可以去自己去扩展它
所以你可以做很多的扩展
我认为这个也是一个rpc很重要的功能
因为它需要适配你内部的大量的一些基础设施
那么第四块
IDL基于文件定义服务
可以通过使用普通的这种工具
生成指定语言的一些数据结构
服务接口和客户端的一些服务端的一些代码
非常方便
IDL我前面也说过
IDL的好处是什么
就是文件及服务定义本身又是文档
它是基本全部统一的
这个是非常好的一件事情
不会存在说你代码改了
文档没有更新
这种情况其实在以前的公司里面经常会遇到
现在的话你只要定义一个Protocol Buffer的描述
这个服务应该输入输出
服务rpc有什么
那么你就可以基于这个文件生成对应的代码
那么这个文件其实也可以作为一个文档去传递
也是非常好用的
还有它的很多设计理念
包括支持元数据传递
包括支持所谓的超时传递
支持那个rpc的取消
这是我后面再讲我们微服务的可用性设计
或者说工程化的可用性设计里面都会去讲
因为可用性设计也是我们非常重要的一个话题
那么rpc框架如果有这些理念在里面的话
我认为也是非常好的
因为rpc毕竟是一个分布式的
然后是跨网络的一个这种这种请求
所以可用性是他很重要的一个理念
还有移动端
为什么说提到移动端这个事情
因为本身gRPC它是over一个标准的HTTP2.0的协议
大家知道很多rpc框架都是自己私有的一个TCP协议
自己有一些这种叫rpc的一些协议的
head pack head或者pack body
它都是自定义的对吧
TCP自定义的话
那么如果你要切到别的公有云的一些设施上
就没有那么方便
那么如果一个rpc是基于HTTP2.0的来设计的话
我认为它over基础设施这种抽象层做得很好的话
未来有可能over HTTP2 over HTTP3.0
我觉得都是可以无缝去切的
那么整个gRPC给你使用的时候
你其实不需要关注它底层是1.1还是2.0还是3.0
其实都没有关系
那么HTTP2.0有些什么好处
它支持双向流
可以相互发出消息
另外它支持所谓的头压缩
让我们的传输体积传输的字节可以字节更小
还有当TCP的多路复用
这个是非常重要的一个特性
大家知道内网很多rpc框架都要建所谓的TCP的连接池
比方说像redis mysql
比方你调a服务
那么你就要需要建立一个对a服务的很多个连接
然后我要搞一个什么连接池去维护
为什么会出现这种情况呢
是因为他的协议设计的时候
是一个你发一个包回一个包
那么这个连接就被独占了
那么你这个时候如果你的请求量很大
你是不是需要一些别的连接来处理这个请求
假设你只有一个连接的话
如果当前那个连接正好在处于一个busy的状态
它正好在忙
你这个时候就没办法
你肯定是需要搞一个连接池
这个问题其实在HTTP1.1里面
问题体现非常厉害
大家知道以前web的页面
chrome浏览器针对某一个域名
它都是会使用连接池的
可能会建立两三四个连接
所以以前我们一个页面上如果图片元素非常丰富的话
我们都会把图片的域名切成什么1234567
就加好多组域名
为什么
因为让你的图片并行加载的速度更快
因为像很多浏览器它在web上
它可能一个域名建立三四个连接
那么如果你有几十张图
那么假设每张图大概取要个几十几百毫秒
你的整个图就像流水线一样去加载
整个渲染出来的速度就会慢很多
那么单TCP多路复用是什么意思呢
指的是我们gRPC的双端 就是对端
server和client只需要一个连接
那么这个连接可以被n个goroutine复用
大家可以拼命的发request
那么它相互之间并不影响
因为大家可以想象TCP是个管道
你发一个request对象发过去
response它也是个管道
其实可以用一个连接来并行发送很多个request
或者说响应很多个response
因为你单TCP做多路复用
实际上就是要考虑一个协议包里面是不是类似有一个ID
你发送
client你发送的时候有一个ID打进去
那么response回来的时候也是把ID带回来
那么你带回来的ID
你就知道是谁发送的
所以说单TCP多路复用可以节省内网大量的连接
并且如果是公网的话
一样的可以节省大量的连接
那么对我们的资源消耗其实也是更小的
那么同时HTTP2.0还支持所谓的服务端的server push
是就支持push消息
以前HTTP1.1都是什么所谓的request response
它无法从server push是一个消息下来
那么这些特性使得gRPC在移动端移动网络环境下
它的设备上是更加省电和节省网络流量的
因为包括像头压缩这些
它其实是效率更高的
我们其实有之前在应该是在QCon上还是在阿克萨姆命台上分享过
我们使用使用gRPC作为公网的一个这种接口怎么去玩的
我觉得是一个比较好的实践
那么还有一些理念叫服务非对象 消息非引用
我们以前在做巨石架构的时候
你要调别人的服务
你通常是怎么做呢
你是不是说依赖别人的一个对象
依赖别人的一个Java的object
然后你可能会reference
引用对方的一个class
所以它是引用
那么大家认真看Protocol Buffer的一个定义
它叫service
它是定义一个service
那么这个地方有一个message
定义一个HelloRequest
我们看一下
定义一个HelloWorldService
它里面有个SayHello的方法
叫rpc SayHello
然后它有个输入和输出
那么它叫HelloRequest和HelloResponse
我们再看一下他在下面就定义了HelloRequest和HelloResponse
那么通过这样的一个很简单的定义服务
定义拿个方法
然后定义输入和输出
我们再看一下它关键字
一个叫service
一个叫message
实际上这句话的意思是什么呢
我们以前的传统的引用导入一个Java的object
然后reference class的思路变成了什么
依赖一个服务
依赖一个消息传递
最终这样的一个方式让你感觉像是什么
促进微服务的系统的架构的演进
鼓励你使用这种系统间粗粒度的消息交互
来促使巨石架构向微服务的架构的转型
就是通过这样的一个文件定义
让你用起来其实像调本地的一个class一样
但是实际上它是个远程调用
因为它是一个叫payload无关的
它可以使用protocol buffers XML JSON都可以
那么在debug的时候
因为你知道用protocol buffer因为它不像JSON很方便去看
其实我们只要在HTTP2.0指定accept-encoding
把它指定为application/json的时候
它实际上可以使用JSON序列化
那么我们在做gRPC的debug的时候
对于测试小姐姐来说
她就会比较方便一些
因为它支持流式的API
什么是流式的API
大家可以看一下官方的一些文档
同时它支持就是所谓的同步调用
和非阻塞式的异步调用
都是支持的
还有rpc里面很重要的就是元数据传递
元数据传递
你可以在rpc call之间传递类似
像链路追踪的一些meta信息
因为我们知道你做HTTP restful
你不会所有东西都会塞到我们的request或者response的消息结构体里面
你可能像IP 用户IP
跟这个协议本身无关的一些旁路的一些这种元数据信息
你通常都会放HTTP head里面
这种情况下gRPC也是一样的
它支持request的head和response的head
你都会携带信息回来
或者带一些请求
带一些head出去
带一些元数据出去
在请求和响应的时候都能带一些元数据
我认为是非常方便的
还有标准化的状态码
客户端就是它非常鼓励就是客户端以有限的方式响应API调用返回的错误
这个地方我待会后面会详细去讲
到底应该怎么来使用错误码
这个其实是个大话题
然后我经常有遇到网上的一些同学说gRPC性能这么差之类的
我的思路是什么呢
任何一个东西它都是先做标准化
标准化了以后解决公司内通信的口径问题
大家都是一种认知
都是gRPC
再去解决性能问题
再去做优化
因为它是背后是Google在推动
随着谷歌背后在支撑
我相信其实包括你现在很多开源的一些第三方的组件
基本上都是会依赖gRPC来做他的
做他gRPC做主要通信的一个API
所以我认为它未来的社区还是很方便的
我们基于刚刚这个定义文件
你只要用protocol buffer的一个工具很方便就生成对应的Go代码
我们使用helloworld.proto这个定义文件
就这个文件
你就可以生成对应的Go的代码
这个就非常简单了
那么讲完gRPC我们在内部是怎么管理API的
我不知道大家怎么做的
我就提一个问题啊
你要知道公司有哪一些服务
服务有哪一些API
你是怎么做的
是不是到处找人问
有哪些微服务
然后让对方扔一个文档给你
对着文档的API去开始编码调用
写一坨client代码
很爽吗
不太爽
是不是很多工作啰里啰嗦的
使用IDL这种方式好处是什么呢
因为所有语言的定义都是一致的
注意所有语言的定义都是一致的
有一个统一的protocol buffer的文件
有了这个文件
我们之前讲过协议即文档
文档即协议
就是这个IDL文件代表着一切
最终它就是一个统一的一种认知
或者说统一的一种契约
那么这个文件我们当时参考了googleapis
也就是这个地方工程
以及看了一下envoyproxy的一个API的定义
我们发现大厂
这些谷歌或者是这种大厂
喜欢把API统一放到一个git仓库里面
很方便去检索和规范
为什么叫规范
API定义如果放到同一个仓库
你是不是可以在git仓库里面使用
一些gitlab的hook或者github的一些hook来做它的一些check
比方说API的规范化的一些检测
你自己可以做一个CI的stage
可能会做一些API的一些style的check
就检测它的规范
你们内部的API就非常容易统一
我认为这个是好处
另外统一仓库以后
你可以统一去一个地方检索和搜索
然后你找到proto文件以后
你就可以看到文档
就文档就是这个定义
所以是非常舒服的
我们内部啊也建立了一个叫bapis的统一的API仓库
那么它完成几个目标
第一API的仓库统一管理统一可见
它是很方便跨部门做协作的
因为大家只会去一个地方找一篇
找文件找文档很方便的
而且我们知道git是支持搜索的
可以搜索里面内容的
所以这个也是很好的
第二统一做版本管理
并且是基于git的
它也很容易去做
cherry peek的回滚也是非常方便的
第三我们刚刚提过
你可以针对我们这种git的统一仓库来做API的code style check
来做它的一些lint的检测
第四每一个API提交
实际上就是发起一个code的建立一个分支
然后建立一个pull request去合到主干
那么你很方便做review的API
首先做API design的review
就设计上没有
同时可以做什么
变更的diff的review
比方说这种变更diff
它是带有破坏性的
我们知道破坏性的API一旦被推上线
影响就很大
那么你可以在事前的阶段也是通过CI的流程
把这种破坏性的变更直接给打回
写一个简单的gitlab的CI的stage
用一些脚本或者用一些工具很容易实现功能
那么git会有个问题
它无法做细粒度的权限控制
大家如果都有合主干的权限
那么都可以随便改别人
所以我们当时也是参考git细粒度权限的一种方式
就是放目录的owners文件表示这个API目录可以被谁来修改
那么大家最终是通过gitlab的评论区
或者像github的评论区
大家有看过一些开源项目
有一个机器人
github的robot或者gitlab的robot
你可以去回复一些指令
比方说你回复一个+approve
表示可以合并通过
它就会自动合并到主干
那么这个+approve这个指令
一旦被我们的gitlab的hook
或者github的hook检测到
它就会触发检测权限
你是不是owner
然后做一些coding style check
你是可以通过这样的方式来把git仓库玩得很炫酷
玩得很好的
这个是我们内部的一个管理API的方式
当然我知道很多其他语言可能会建一个外部UI页面
把服务注册上去
然后那个界面里面可以查到API的定义
这也是一种方式
但是我认为git就是最完美的一个工具
没有比它更好的
你们可以想想 它可以做很多功能
版本检测回滚等等一系列的功能
根本不需要你写什么代码
只要你写一些简单的插件就可以完成的
所以我非常鼓励这样的一个方式来协作
真的非常鼓励
我们如何去在git的
就是API Project Layout应该是长什么样子呢
在具体的一个微服务的项目中
我们之前说的我们是有一个API目录的
在目录内我们会把它的以API为包名作为一个目录
然后我们会写上它的服务的一个名称
类似这样写上
服务名称加上版本号
就这样去区分目录
最终放上API的定义
也就是说我会把它的完整的目录的定义写上
就不是说api/api.proto
我是会把API里面
再把它这个服务怎么命名的
去把它划成多级目录去放
为什么呢
是因为微服务的API目录
建立一个微服以后
我可以自动的把API拷到同步到我们的统一的git的仓库里面去
它可以直接搬过去
所以这样的话目录结构映射就比较清真一点
API的git仓库我们内部是长什么样
我们外面看一下API根目录
bapis这个是我们公司内部的项目名
然后api这个是统一放API的
里面就是具体的一个echo v1版本的一个微服务
注意这个地方的目录是可以把这个地方直接拷过去的
就直接copy一份到这边
那么同级的API目录里面还有一个rpc目录
rpc目录可能放内部的一些状态
还有一些metadata
我们API统一的内部的一些元信息
所谓的API的一些metadata的定义
比方说有多语言
可能有网络一些元数据
可能有设备一些信息
那么OWNERS文件表示这个目录权限是归谁所有
大概是这样一个demo
当然我们还会放一些其他的
比方说第三方的一些引用
然后annotations一些自定义的protocol buffer的一些options
也会放到这样的
也是会放到里面的
大概是这么一个结构
除了这个目录结构
我们其实讲API设计里面很关键的是什么
兼容性
我们看一下什么叫向后兼容的修改
像一个服务一个gRPC服务去添加新的接口
我认为是安全的
从协议角度来说通常都是新接口都是比较安全的
给请求消息添加字段
那么新字段和老字段
如果你能保证老字段的逻辑不变化
就是老的人爱用不用
其实也是向下兼容的
那么可以响应添加字段也是一样
它引入一个冗余字段
你只要保证你的老字段仍然会被填充
那么它语义上就是向下兼容的
那么其实讲到这个
我们就要回溯一下
我们讲数据库的变更其实也是一样
我们很少会去改一个字段的定义
因为这样它的副作用实在是太大了
通常都是新增字段
所以我们既不会改老的字段
也不会去删除老的字段
我们往往都是说新加一张表
对表里面的新增一个字段
那么通常来说它都是什么向下兼容的
那么可能API的兼容性其实大差不差
那么什么情况下它是不向后兼容的
你把方法删掉了
别人还在用
你删字段删方法删枚举
其实都是会破坏的
那么这种破坏性的变更一定要加major版本号
也就是大版本号
以前是v1 现在叫v2
v2整个全部要copy一份出来
注意比方说v1是这么多方法
那么你v2有一个大破坏性变更
应该是拷所有的方法出来
然后把你刚才有大破坏性变更那个方法也带上去
那么它一定是v1 v2两个大版本去区分的
那么修改字段类型
某一个方法里面的含义语义
业务逻辑变化
它都是破坏性的变更
这些都是要注意的
一定不要这样干
除非你是一个大版本的变更
我们再来看一下
我们一个API怎么去命名的
其实别看这个问题
哪怕你是用HTTP的restful
其实很多情况下我们命名都很烂
我们以前用内部的gin框架或kratos v1的时候
我们HTTP的接口命名简直非常糟糕
就是那个location
HTTP的那个location
那个url基本上是乱命名的
其实比较好的姿势是什么
我们看一下
一个非常好的request的url应该是先是包名
你对外一个服务它肯定是所谓的有个package
有个包名的
所以先是包名
版本号服务名
然后再加上方法名称
你不管是用gRPC还是不用gRPC
你的location最好都是用这些元数据来的
那么它用这样的一个标准的命名方式和你的服务定义的名称
我们知道每个服务的唯一名称
它都能关联上
版本号也在里面了
服务名也在里面了
然后方法名在里面
你的package名也在里面
所以它是非常好去收敛和统一管理的
我们这个地方看
包名我们用的什么
我们是用的应用的唯一标识就是appid
比方说a.b.c
我们看一下
这个地方谷歌它就是用的什么
googleapis.com
然后这个叫什么日历
这个是它的应用的标识
这个叫服务的一个名称
它是用这个名称来做包名的
最终其实你一旦包名生成路径的话
其实我觉得它是一种很规范的一个做法
我们这个地方你看一下谷歌的
它整个命名最终接口名称就是包名加版本号加上服务名称
你的里面还有具体的一些方法
就是什么CreateBook GetBook ListBook
我们看这里demo
它包名就是google.example.library.v1
他每一个包名一定有版本号
前面可能是一个包的名称
那么这个地方就是服务名称
这个部分我们一般都是用应用的ID
应用ID我们在第一周讲微服务的时候
其实也讲过它的命名
它的命名就是按业务去定义
什么a.b.c
按业务去分级定义
非常好
我们再看一下标出的地方
CreateBook GetBook
我们通常每一个rpc的方法的输入request很少会去复用的
我们都会给每一个方法定义一个输入
我们甚至说每一个rpc它的输出我们都会定义
但这个地方我们看一下它比较比较特殊一点
它复用的一个Book
因为输入可能是每个人都不一样
因为你如果要复用一个结构体的话
换一方法一变化新增几个字段
你另外一个方法不需要
那就很尴尬了
所以说每人定义是比较划算的
那么输出可能不一样
输出可能大家都是返回用户
你是query一个用户
根据ID找用户
根据名称搜索用户等等
它都是返回用户的一些某种返回用户的一些信息
那么这些偶尔情况下
它是可以不是每一个rpc方法都定义一个响应输出的
就是你可以复用一个
那么如果你是空想响应的话
一般来说会返回一个google.protobuf.Empty
它有个特殊的Empty的结构体定义
那么这样的话表示一个空回复
注意啊
这个里面其实有一些小技巧
最终我建议大家去看一下Google API Design Guide
谷歌的API的设计的一个指导手册里面
有好多关于API怎么定义
怎么取名字
怎么保证兼容性
那篇文章写得非常好
我这里面其实也是参考了很多他的一些方法论来搞的
那么还有一个问题
我们知道API里面其实很麻烦的一个是什么
所谓的零值
先看一下Protobuf v2
它那个时候是区分字段是必选项还是可选项
它加了option字段
我们知道像Go里面也是一样的
要区分一个对象是零值
还是设定值
其实无法区分
如果它是个基础类型
比方它是个int
你不知道它到底是设定等于0
还是说压根这个参数没有传递
无法区分
那么以前的话Protobuf v2的话
它加了option
所以会把所有的基础类型变成指针
它会加一个方法叫hasXxx
就是先调这个方法判断有没有字段
如果有的话
再去getXxx就是获取它
我不知道为什么它v2这个就作废掉了
其实现在v3的话
所有的都是可选类型
但是可选类型你是无法区分默认值还是没赋值的
那么这个地方我就提一个在Protobuf v3中
通常会建议用wrap struct
什么叫wrap struct
简单来说假设你有个基础类型
它会额外定一个叫DoubleValue
DoubleValue内部其实又包含了一个double value
那么这个message它其实就变成一个什么
变成一个结构体
那么变成结构体
说白了它就是变成指针了
你可以判断它是不是等于nil
那么你就知道它是没填值还是填了等于0
Protobuf的定义这种玩法
它这种所谓的这种定义
我认为它是一种强schame
就是一个强类型约束的一种文件定义
也是很方便后续去做扩展的
所以我抛一个观点
Protobuf的定义
既然它是强约束的
并且是很利于被阅读的
是不是后续也可以用于像配置文件的一些定义
我们配置文件从来都是很松散的
大家知道配置文件有什么模板吗
对吧
它都是一个模板拷来拷去的
或者说根本就没有模板
你都是要去看代码才知道它配置文件长什么样的
经常配置写错之类的
但是如果你有定义的模板
就是通过Protobuf去描述
也是很好的一种方式
而且通过Protobuf你甚至可以写一些扩展
在Protobuf里面既可以加注释又可以去加上一些validation
所谓的规则校验
那么很方便做更高级的一些扩展
第三块内容讲我们的讲工程化配置管理的时候
我就会讲这个思路
非常有意思
好 我们要讲很核心的一个内容
就是API的错误的处理
其实是很容易出错误的地方
或者说大家踩坑的地方
我刚刚抛了一个观点
很多人都是用HTTP状态码200
都是用200
在gRPC的规范里面它提了几个点
第一比方说有个资源找不到
用户找不到
稿件找不到
收藏夹找不到
有大量的找不到
我们以前都会什么
每一种都会定一个错误
结果搞到后面整个物业服务定义出几百甚至上千个错误
但是有没有想过
我们不需要给每一种找不到这种错误
去定义一个专有错误
是不是可以用一个标准的
google.rpc.Code.NOT_FOUND 错误
代表告诉客户端找不到某个特定资源
相当于用一个大类的错误
我们叫大类错误
比方说大类错误叫404
表示某种东西找不到
那么你可以在body里面
甚至在head里面带上一些小类错误
告诉你具体是什么找不到
那么我在客户端在处理的时候
我天然就知道它是某种东西找不到
它是个大类错误
因为这样的话
整个会让你的错误的定义减少很多文档的复杂性
因为我优先通用的大类错误表示
我不需要搞的每一个详细的全部列出来
让我的文档变得更简单
另外在客户端库中提供了更好的惯用的映射
我知道天然就知道404是找
不到降低了客户端的逻辑复杂性
同时用这种404或者是5xx
你先可以携带一些扩展信息用于一些更精确的处理
如果你不需要精确处理
可能就是个404加上一个message
告诉你什么找不到就足够了
它就是标准的404
没有一个什么12345678这种错误
你是通用错误
吃掉了一大波这种小错误
然后你需要被特定客户端需要处理的
你再带上一些
挂上大类错误之后再挂上一些小类的reason
它的原因是什么
或者说它需要做一些什么样的action
你会放一些扩展信息进去
所以我的思路是什么呢
不需要被详细错误的
被详细处理的统统返回大错误
需要被详细错误的
你就带上它的reason
它的根因是什么
带上它的一些operation
它需要被怎么操作
这个是第一个思路
第二个叫错误传播
这个问题
我当时遇到这个问题是什么
我们在BFF层它会调很多微服务
每一个微服务都会返回一些错误码
并且每一个微服务还会依赖下游的一些微服务
最终每一个微服务返回的错误
在BFF层它就是一个大的合并
错误码的合并
那么这个时候BFF层在API文档里面要描述它会反馈哪些错误
是完全无法枚举出来的
因为你压根都不知道会返回什么错误
所以我们当时对外层BFF的JSON
只会告诉客户端这几个特殊的错误是这几个意思
剩下的不知道你们不知道会反馈什么
这是第一个问题
第二个问题
为了避免微服务之间的错误冲突
就是错误码的冲突
我们一般是a部门有01开头
b部门用02开头
搞一搞这种什么所谓的这种全局错误这种方式
我认为也不好
因为这种全局错误码的定义
它是松散的易被破坏的一种君子的契约
它是很容易被破坏的
那么怎么办
我认为在a你在调别人的时候
我们不应该盲目的将下游服务的错误无脑的往上抛
我应该做什么
应该做两件事情
第一调下游错误的时候
你应该在返回错误的时候
至少要隐藏掉实现的详细信息和机密信息
比方说我甚至见过有人一个sql报错
什么password nil一个字符串把密码不小心爆出去了
然后说sql的语法错误然后抛到了外网
不应该把内部的exception抛出去
要吞掉这些debug的
这些实现细节的一些信息
这是第一个安全考虑
第二调整负责该错误的一方
什么意思呢
调整负责该错误的一方
比方说从另外一个服务接收了一个叫invalid argument
那么你的调用者应该将错误转换成
你自己可以识别的某种错误再向上抛
比方说你调他
你对这个错误很感兴趣
比方说是余额不足
余额不足你在你这个业务体系里面你会用到
你会把这个错误干嘛
转换成你自己的错误
注意它可能返回的是100
那么转换成你的可能就等于8
也就是说你要做一个错误的转换
那么你对这个错误感兴趣
你就要做转换
那么你如果不感兴趣
你就往上游直接抛一个500 unknown
不知道intenal内部错误
不是所有错误你都感兴趣
你这个服务调它的时候
你就会往上面直接抛500
因为它是什么internal内部错误
这个是一个思路
那么如何解决彻底解决全局错误码的问题
也就是说在做错误的传播的时候要做一次翻译
因为每个服务它一定能保证自己的错误是唯一的
这个都保证不了
肯定是你的bug
你肯定是能保证自己错误唯一
那么你调别人的时候
如果你对这个错误码感兴趣
你一定会按照你的返回的错误码去抛出去
这样的话我的微服务
每一个rpc会返回什么错误码
一定可以讲得清楚
因为我做了一次翻译
我不是直接无脑透传使用它的
我做了翻译
那么不感兴趣的我当然就直接返回500
你的服务是一个domain
是一个作用空间
加上你自己微服的错码定义是唯一的
它是不是本质来说从全局它就是一个唯一的
这样的话BFF调每一个微服的时候
因为它要做统一的错误码的翻译
也就是说最终BFF对感兴趣的一定会翻译出来
不感兴趣的统统500
因为我们通常感兴趣的其实也就七八个
不会都感兴趣的
除了公共错误以外
感兴趣的业务错误的其实就那么几个
对吧
所以说你这样的话
就有办法在你的API文档里面终于讲得清楚
你的a会反馈哪些错误
并且这个错误它的唯一性怎么保证
实际上是调到哪一个服务
调到哪个服务的rpc
它来保证这一个错误码的唯一性
错误码的唯一性是跟服务来绑定的
注意它是跟服务来绑定的
所以说我调a服务等于123是这个含义
调b服务123是另外一个含义
它们天然不冲突
当然我们前面讲了
有一些大类的错误
公共错误
那些是公共的
除此之外这些公共错误可以挂载一些小错误
那些小错误就是刚刚说的跟每个服务挂钩的
基于这些理念
我们就设计了kratos的一个error的处理
其实想了很久
包括现在都还在还在内部
大胆的讨论到底什么是一种最佳实践
我们先看一下我们的kratos是怎么做的
我们先切到github
我们找到kratos的我们设计的一个errors的包
我们先看一下
我们定义了一个叫做StatusError的
定义的这么一个结构体
它会包含Code就是大类错误
我刚刚讲的状态码
其实就是HTTP状态码或者是gRPC状态码
Reason这个Reason是个字符串
实际上就是我们叫小类错误
就是小类业务错误
Message其实是跟Code的一一对应的
比方说404是Not Found
它是给我们程序员去阅读使用的
那么这个Reason
你可以理解为Code是大类错误
Reason是小类错误
我们几个使用方式
你可以调Errorf把code传进去
把Reason传进去
然后还有这个地方是放Message的
它可以放一些详细的Message
所以就是这个Errorf
当然了我们因为有一些是比方说404
我们又做了一些封装
我们怎么封装我们看一下
比方说我们可能会返回500错误
我就会让你调Unknown这个方法
那你就可以传小类错误和Message
你可以自定义传
包括你比方说可以传504
你可以讲自己的Reason
加上一个format信息
相当于我们把大类错误错误码在这个地方写入进去的
也就是说我在业务层
我的biz层业务逻辑层
甚至在我的data层
我都可以依赖kratos errors
我就直接可以return一个比方说DeadlineExceeded
然后带上一个reason
带上具体的报错的一个reason
当然了
我们相当于把错误归类成大类和小类
但是也可能你不想填reason其实也是可以的
可以填空
表示它没有任何的小类
根因我只想让你关注它是个大类错误
那么这样用起来其实是我认为是非常方便的
那么除此之外我们再看一下
NotFound 对吧
这样是生成一个NotFound的大类的错误
加reason的一个小类的错误
我其实也可以判定用IsNotFound
判断它是不是404
你可以传一个error进来
那么在内部的话
我们会先用Go1.13
大家应该知道Go1.13 error有Is和As两个新增的方法
我可以用As去调内部的unwrapper
去找到它的根
因你传的error可能是被人wrap掉了
其实它可以这样的话获取到它的根因是什么
因为你可以拿到它具体类型
我就可以做它的code码的判定
我就知道它是NotFound
如果我要再判定他的reason的话
我要判断的小类错误的话怎么办
我就可以调这个Reason方法
我们看一下一样的
先是用Go1.13的unwrapper拿到它的status错误
然后拿到这个错误以后
我再返回它的Reason
把它的Reason给返回回来
我就可以去跟你的业务的Reason字符串做一个什么匹对
我就知道它是不是某类小类错误
所以这个是我们在kratos框架里面非常推崇的一种姿势
那么这个kratos errrors定义之后
其实你最终还要通过HTTP或者是gRPC传递
它就需要做一个翻译的过程
就是把这些信息映射到HTTP的状态码
映射到gRPC的状态码
映射到gRPC的details去做rpc的传递
或者是挂载到HTTP的JSON的body里面
所以这个叫错误码的一个序列化的方式
那可以大家看一下我们kratos的HTTP或者gRPC的处理
你就知道它怎么去做翻译的过程了
其实我就不展开讲了
因为它的整个error的处理是比较简单
我们快速看一下HTTP怎么处理的
我们看一下有一个定义的叫EncodeErrorFunc的一个这样的方法
我们有一个默认的内部的encodeError的函数指针
我们看一下
我们就会先调FromError
获取到它具体的错误
然后其实就是把它序列化
比方说JSON序列化
然后我们再设置HTTP的HTTP头
这个地方是设置HTTP头
然后再把它JSON序列化一下返回回去
所以这个地方先是获取
我们看一下这个FromtError
其实就是你你传递一个任意的error
我尝试把它转换成这个StatusError
就是我们kratos内部的StatusError
如果转化不成功
我就会返回什么
unknown内部错误
可能未知错误
如果能转化成功的话
我就会获取它的序列化的方式
比方说是JSON序列化还是Protobuf序列化
我会获取它序列化的方式
然后再这个地方应该是设置它是用什么方式序列化的
然后这个地方是设置HTTP状态码的头
然后这个地方就是把序列化的方式JSON吐出出去
其实我们的一个模型就是
能够把error做一些从kratos errors转换到HTTP怎么传递
或者gRPC怎么传递
OK我们继续我们再来看一些API Design的一些规则
那我们用gRPC有一个非常有意思的点
比方说我们有一个UpdateBook的方法
那么它会return Book
这个是我们看有个LibraryService的UpdateBook
假设一开始这三个字段123
name名称 作者 还有这个标题是什么
大概是这么一个定义
假设我的读接口需要返回
这个用户是不是对Book读过了
是否这本书读过
我一般我们常见做法是怎么做
会加个read对象对吧
就加一个bool read的字段
但是大家也知道这样做法是什么坏处吗
如果你的结构体被很多人复用
有些字段a方法用
有些字段b方法用
会给做API的调用者或者是实现者它会带来一些歧义
因为有些是给a方法用的
也就是这样给b方法用的
但是如果你每一个方法都去定义一个结构体
它要大量这种冗余
所以说gRPC它比较推荐的一个方式叫部分更新
就有一些字段需要更新
有些地方不需要更新
最典型的例子是什么
大家打开一个页面
那页面里面你可能有很多个字段
比方你的个人信息
什么名称 头像 还有什么个人简介
我们通常一般会改好几个地方
我们可能会改了昵称
也可能改了自我介绍
然后点了保存
其实地方它改了两个字段
我们一般是怎么做
从页面上把所有字段全部捞出来
吐到gRPC Update
然后所有字段都会去更新一下数据库
但是实际上你里面只改了一个字段或者改了一两个字段
那么用这个FieldMask
可以标示出来哪些字段被用到了被set了值
你看这个地方它就改了一下
还是UpdateBookRequest
但是它加了一个叫FieldMusk
来标示出Book字段里面哪个字段带上的值
否则你在API设计的时候你会搞很多小方法
Update的头像Update自我介绍什么的
就添加一堆方法
或者你搞了一个大方法
但是其实是有一定风险
万一哪个界面上出了一些bug
然后把用户的信息更新成空的
其实也是有风险的
所以我们一般对这种接口不会说提供一堆
什么改头像改名称
我们就给一个叫UpdateUser
然后通过FieldMusk标示出用户信息里面哪些字段被更新了
这样的话我去写这个逻辑就相对来说比较简单
那么对于读场景有些字段的确是可以忽略掉
我觉得这个算是一个比较
比较巧妙的一个办法
在API Desgin里面更细节的我也不展开讲
因为API在里面的的确确有非常大的原则
我们无法短时间全部描述完
我建议大家一定要认真看一下
Google的API Design Guide
在我们reference里面也有它的一个链接
大家可以去找一下
好 关于 API的设计我就讲这些了
我们后面开始要讲配置管理
一个项目的配置管理真的是可以出很多很多的bug