OpenIM 的 API 接口设计

问题

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

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

所以就存在很多问题:

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

方法

利用 protobuf 来定义接口的方式非常令人心动,因为 protobuf 当中包含了接口的函数签名,入参和返回值同时还支持注释,就是一份天然的文档,同时也不用担心出现代码更新了但是文档没有更新的情况,因为它既是文档也是代码,服务端也需要使用,所以代码更新之后文档也一定会更新。自然而然的就少了很多沟通的成本。

api 定义方式 (1).jpg

如上图所示于此同时我们还可以利用 protobuf 文件生成对应语言的客户端代码,就不用每个项目都去维护一套 sdk 了,同时我们使用接口生成代码,在 go 当中可以使用 gomock 非常方便的对代码进行 mock。

参考项目:

API Project

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

统一存放 api 定义文档,然后通过 ci/cd 生成对应的客户端代码放到各个语言的子仓库当中

image-20231108171738096

工作流程如上图所示

  • 开发同学修改了 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 的兼容性就会很重要了,如果我们做了一些破坏性的变更就有可能会导致依赖我们的服务或者是客户端报错,这样就会带来事故。

向下兼容的变更

  • 新增接口
  • 新增参数字段
  • 新增返回字段
    • 在不改变其他响应字段的行为的前提下,非资源(例如,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 应该都是标准的

示例

// 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

{
  "code": 1,
  "msg": "xxx",
  "data": {}
}

这种做法就存在一个比较大的问题,做监控的时候不太好做,很多现成的东西没有办法直接使用,因为我们都返回的成功。

参照 google 的错误定义,将 http code 和 grpc 错误码进行映射,返回对应的错误信息

但是这样还是不行,因为这样很多业务错误信息无法区分,毛老师他们的 kratos v2 的做法是做了两层,使用下面的方式进行定义

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

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

Gin 路由注册

💡简单的一个案例如下:

package main

import "github.com/gin-gonic/gin"

func handler(ctx *gin.Context) {
	// get params
	params := struct {
		Msg string `json:"msg"`
	}{}
	ctx.BindQuery(&params)

	// 业务逻辑

	// 返回数据
	ctx.JSON(200, gin.H{
		"message": params.Msg,
	})
}

func main() {
	r := gin.Default()
	r.GET("/ping", handler)
	r.Run() // 监听并在 0.0.0.0:8080 上启动服务
}

这是一个简单的示例,可以发现 gin 注册路由需要一个 func (ctx *gin.Context) 签名的函数,这个函数一般做三件事,获取参数,调用业务逻辑,调用 gin 的方法返回 http response

grpc server interface

先看一下 proto 文件中的 rpc 定义,一般就是包含一个参数和一个返回值的函数

// The greeting service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

然后看 grpc 生成的接口,其实和 proto 文件一一对应,只是多了一个 context 和 error

type GreeterServer interface {
	// Sends a greeting
	SayHello(context.Context, *HelloRequest) (*HelloReply, error)
	mustEmbedUnimplementedGreeterServer()
}

所以问题来了,我们让 service 层实现类似 GreeterServer 接口就行了,那我们代码生成器要怎么写才能够应用到 http 上呢?

概要方案

  1. 我们需要从 proto 文件中得知 http method,http path 的信息,这样我们才知道要注册到哪个路由上

    1. 这个可以通过 google/api/annotations.proto 为 rpc 方法添加 Option 实现
    2. 或者是通过函数签名来约定,我们约定方法名使用驼峰方式命名,首个单词是 http method 或者是 http method 的映射,如果都不是默认采用 post
      1. "GET", "FIND", "QUERY", "LIST", "SEARCH" –> GET
      2. "POST", "CREATE" –> POST
      3. "PUT", "UPDATE" –> PUT
      4. "DELETE" –> DELETE
  2. 我们需要构建

    func handler(ctx *gin.Context)
    

    函数用于注册路由

    1. 函数内需要处理参数,用于调用 service 层的代码
    2. 调用 service 层的代码结束之后,将返回值调用 gin 相关方法返回

所以我们最后生成的代码大概应该是这样的:

type GreeterService struct {
	server GreeterHTTPServer
	router gin.IRouter
}

// 生成的 gin.HandlerFunc
// 由于 HandlerFunc 签名的限制,不能从参数传递 service 接口进来
// 所以我们使用一个 Struct 托管 service 数据
func (s *GreeterService) SayHello(ctx *gin.Context) {
	var in HelloRequest

	if err := ctx.ShouldBindJSON(∈); err != nil {
		// 返回参数错误
		return
	}

	// 调用业务逻辑
	out, err := s.server.(GreeterHTTPServer).SayHello(ctx, ∈)
	if err != nil {
		// 返回错误结果
		return
	}

	// 返回成功结果
	ctx.JSON(200, out)
	return
}

// 路由注册,首先需要 gin.IRouter 接口用于注册
// 其次需要获取到 SayHello 方法对应的 http method 和 path
func (s *GreeterService) RegisterService() {
	s.router.Handle("GET", "/hello", s.SayHello)
}