Go工程化(四) API 设计上: 项目结构 & 设计

本系列为 Go 进阶训练营 笔记,预计 2021Q2 完成更新,访问 博客: Go进阶训练营, 即可查看当前更新进度,部分文章篇幅较长,使用 PC 大屏浏览体验更佳。

3 月开始会尝试爆更模式,争取做到两天更新一篇文章,如果感兴趣可以拉到文章最下方获取关注方式。3 月进度: 01/15

Go 工程化(二) 项目目录结构 中我们大概讲了一下 api 目录,但是并没有详细的说明,留在这这篇文章一起讲。

API 设计将分为四个部分:

  • 首先会讲一下 API 的项目目录结构,在项目中 api 该如何组织,以及 api 依赖该如何处理
  • 第二个部分会讲一下 API 该如何设计,包括错误码的设计
  • 第三个部分会讲一下如何构造一个 protobuf 的代码生成器,自动生成 gin 相关代码,这个是因为我们目前主要是 http 的服务,grpc 相关基础设施建设不完全,所以依赖现有的基础设施得到更好的体验。并且做到在 service 层的代码支持 grpc 和 http 多种方式,使后续架构变化更加灵活。
  • 第四个部分会给出一个 demo,辅助大家更好的理解

由于篇幅原因拆分成了两篇文章,本文涉及到一二部分,下一篇文章为讲解三四部分

API 项目结构与管理

API 定义方式

b 站内部主要使用 grpc 作为内部通信的方式,因为他使用 protobuf 文件定义可以支持对语言代码生成,同时还避免了手写文档导致的文档错误过时等情况,具体的原因其实在第一课的笔记当中就有提到,如果感兴趣可以查看 微服务(二) 服务发现&多租户#gRPC

我们目前使用类似 http restful 的方式进行对外对内提供服务,但是我们之前的 API 管理其实是比较混乱的,分为以下几种情况:

  1. 暴露给 web 的 api:有使用 swagger 的,有在文档平台上写文档的,还有没有写文档的
  2. 暴露给其他服务调用的 api: 有注册到内部的接口网关的,但是内部的接口网关上有的有参数,有的没有,没有返回值定义

所以就存在很多问题:

  1. 想要接口不知道从哪儿找,只能到处问人
  2. 有时候从内部网关平台上找到接口但是不知道怎么调用,没有写任何参数,有的写了还有可能是错的
  3. 有的压根没有接口文档,对接的同学也没有时间写,然后让你直接看代码
  4. 有的对接同学扔给你一个接口文档,然后试了半天发现,有问题,沟通排查之后发现文档很久没有更新了 o(╥﹏╥)o

所以课程上毛老师提到的利用 protobuf 来定义接口的方式非常令人心动,因为 protobuf 当中包含了接口的函数签名,入参和返回值同时还支持注释,就是一份天然的文档,同时也不用担心出现代码更新了但是文档没有更新的情况,因为它既是文档也是代码,服务端也需要使用,所以代码更新之后文档也一定会更新。自然而然的就少了很多沟通的成本。
api 定义方式 (1).jpg
如上图所示于此同时我们还可以利用 protobuf 文件生成对应语言的客户端代码,就不用每个项目都去维护一套 sdk 了,同时我们使用接口生成代码,在 go 当中可以使用 gomock 非常方便的对代码进行 mock。

API Project

使用 protobuf 定义接口可以解决我们找到 api 文档之后,文档不准确,缺失的问题,但是我们应该如何找到我们的 api 呢?我们生成出的 api 文件调用方应该如何引用呢?难道我们给每个调用方都去开一个项目的权限么?那明显是不太行的,接下来我们就看看我们 api 该如何管理和组织。

毛老师他们仿照 googleapis/googleapisistio/api 等知名项目在 b 站内部搞了一个 bapis 的仓库用于同一存放 api 定义文档,然后通过 ci/cd 生成对应的客户端代码放到各个语言的子仓库当中
Frame 1 (1).jpg
工作流程如上图所示

  • 开发同学修改了 proto 文件定义之后 push 到对应的业务应用仓库当中
  • 然后触发 cicd 流程将 proto 文件复制到 api project 当中
    • 首先会对 proto 文件进行静态代码分析,查看是否符合规范
    • 然后 clone api project 创建一个新的分支
    • 然后 push 代码,创建一个 merge request 请求
  • 然后我们对应负责的同学收到 code review 的通知之后进行 code review,没有问题就会合并到 api project 的主分支当中了
  • 然后就会触发 cicd 生成对应语言的客户端代码,push 到对应的各个子仓库当中了

API Project Layout

我们的 api 项目是如何定义的呢?看下图
Frame 1 (2).jpg

  • 首先是在业务项目当中,我们顶层会有一个 api 目录
    • 在 api 目录当中我们会按照 product name/app name/版本号/app.proto 的方式进行组织
    • 具体怎么组织可能每个公司都不太一样,但是总的来说就是应用的 唯一名称+版本号 来进行一个区分
  • 在 api project 当中和业务应用类似,也有一个 api 目录,通过上图的两个框就可以发现这是一模一样的
    • 除此之外 api project 还有用于注解的 annotations 文件夹
    • 有一些第三方的引用,例如 googleapis 当中的一些 proto 文件

API 设计

API 兼容性设计

随着应用的不断开发,业务的不断发展我们的 api 肯定会不断的进行修改,在修改 api 的时候考虑 api 的兼容性就会很重要了,如果我们做了一些破坏性的变更就有可能会导致依赖我们的服务或者是客户端报错,这样就会带来事故。

向下兼容的变更

  • 新增接口
  • 新增参数字段
  • 新增返回字段
    • 在不改变其他响应字段的行为的前提下,非资源(例如,ListBooksResponse)的响应消息可以扩展而不必破坏客户端的兼容性。即使会引入冗余,先前在响应中填充的任何字段应继续使用相同的语义填充。

一般而言新增都是相对安全的,但是我们要注意的是新增字段不能改变我们原本的逻辑,如果改变了 api 的逻辑,那就不一定安全了

向下不兼容的变更(破坏性变更)

  • 删除或重命名服务,字段,方法或枚举值
    • 在做这种修改的时候需要修改我们 api 的版本号,常见有两种方式
    • 如果只有很少的 api 变动可以创建一个 XXXV2 的方法
    • 如果变动的 api 比较多,可以直接新启一个 v2 的包
  • 修改字段的类型
    • 严禁修改字段的类型,修改字段的类型可能会导致客户端崩溃
  • 修改现有请求的可见行为
  • 给资源消息添加 读取/写入 字段

API 命名规范

包名

产品名product
应用名app
版本号v1
包名product.app.v1
目录结构api/product/app/v1/xx.proto

API 定义

  • 命名规则:方法 + 资源
  • 标准方法:参考 Google API 设计指南
标准方法HTTP 映射
ListGET
GetGET
UpdatePUT 或者 PATCH
CreatePOST
DeleteDELETE

除了标准的也有一些非标准的,例如同步数据可能会用 Sync 等,不过大部分的 api 应该都是标准的

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// api/product/app/v1/blog.proto

syntax = "proto3";

package product.app.v1;

import "google/api/annotations.proto";

// blog service is a blog demo
service BlogService {

rpc GetArticles(GetArticlesReq) returns (GetArticlesResp) {
option (google.api.http) = {
get: "/v1/articles"
additional_bindings {
get: "/v1/author/{author_id}/articles"
}
};
}
}

注意,一般而言我们应该为每个接口都创建一个自定义的 message,为了后面扩展,如果我们用 Empty 的话后续就没有办法新增字段了

API Error

错误定义

先说我们当前的问题,我们一直用的 http 然后我们返回是使用的下面这种格式,然后 http code 统一返回 200

1
2
3
4
5
{
"code": 1,
"msg": "xxx",
"data": {}
}

这种做法就存在一个比较大的问题,做监控的时候不太好做,很多现成的东西没有办法直接使用,因为我们都返回的成功。
参照 google 的错误定义,将 http code 和 grpc 错误码进行映射,返回对应的错误信息
image.png
但是这样还是不行,因为这样很多业务错误信息无法区分,毛老师他们的 kratos v2 的做法是做了两层,使用下面的方式进行定义

1
2
3
4
5
6
7
8
9
10
message Status {
// 错误码,跟 grpc-status 一致,并且在HTTP中可映射成 http-status
int32 code = 1;
// 错误原因,定义为业务判定错误码
string reason = 2;
// 错误信息,为用户可读的信息,可作为用户提示内容
string message = 3;
// 错误详细信息,可以附加自定义的信息列表
repeated google.protobuf.Any details = 4;
}

和我们当前的方式差不太多,但是我们是在原来的基础上返回了 http code,剩下的字段还是和原来保持一致

错误传播

这一点我们之前做的还行,错误传播这一部分很容易出的问题就是,当前服务直接把上游服务的错误给返回了,这样会导致一些问题:

  • 如果我调用了多个上游服务都报错了,我应该返回哪一个错误
  • 直接返回导致必须要有一个全局错误码,不然的话就会冲突,但是全局错误码是很难定义的

正确的做法应该是把上游错误信息吞掉,返回当前服务自己定义的错误信息就可以了。

总结

毛老师课上讲的 api 设计思路用起来还是挺爽的,我们已经在一个项目当中进行了试点,cicd 的流程也跑了起来,最爽的一点就是终于不用找接口文档了,然后还节省了一些代码量,我们之前的接口调用方式都是十分原始的,每个项目都自己去封装相关的 sdk 然后我们对单元测试还有要求,http 接口的 mock 是挺麻烦的事情,通过 protobuf 定义接口之后我写了一个结合内部网关的 sdk 代码生成器,直接生成相关接口代码,go interface 的 mock 实现也在 ci 流程中生产好了,调用方只需要调用不同的实现就行了。
下一篇我们就通过写一个 从 proto 生成 gin 代码的生成器来看看这个代码生成器改如何实现。

参考文献

  1. Go 进阶训练营-极客时间
  2. GitHub - istio/api: API definitions for the Istio project
  3. GitHub - envoyproxy/data-plane-api: [READ ONLY MIRROR] Envoy REST/proto API definitions and documentation.
  4. GitHub - googleapis/googleapis: Public interface definitions of Google APIs.
  5. API 设计指南 | Google Cloud

第 0 期已经结束,想要报名后面课程的同学,我联系极客时间为大家争取到了读者专属优惠
扫描下方微信公众号二维码,发送【福利】获取专属优惠,比官方优惠更给力哦

关注我获取更新

wechat
知乎
开发者头条
github