跳到主要内容

APIServer 开发

本文讨论的 APIServer 指的是微服务架构下的典型的Web业务服务后端,一般由一个小团队维护,有特定的业务模型,通过暴露一系列的接口对外提供系统边界内的功能。 本文的讨论主要和WebServer强相关的、偏技术实现细节。比如代码分层。

期望能通过对 APIServer 的讨论,将其通用的模式进行归纳,提升业务开发。

本文所有讨论的技术细节的语言是基于go的。

典型的工作模式

一般而言,一个业务后端的工作模式如下:

                                 +-----------------------------+
+------------------+ | |
| +-------------> +-------------------+ |
| | | | | |
| Client | | | route dispatch | |
| <-------------+ | | |
+------------------+ | +---------+---------+ |
| | |
| | |
| | | +-----------------+
| +---------v----------+ | | |
| | |<--+----------------> 3rd server |
| | | | | |
| | | | +-----------------+
| | biz handle | |
| | | | +-----------------+
| | | | | |
| | |<--+----------------> storage middleware
| +--------------------+ | | |
| | +-----------------+
| |
| WebServer |
| |
+-----------------------------+

其特征为:

  • C/S 架构:一般是客户端找到服务端,两者开始通信
  • 使用Socket进行通信:上层协议有 gRPC、 RESTFul, GraphQL等
  • 大多数WebServer本身是无状态的(可以水平扩缩容),在处理业务逻辑时会访问存储中间件,比如MySQL

C/S通信协议

当前,Client 和 Server 使用 Socket 进行通信。传输层一般是使用 TCP协议,也可能是UDP协议。

+----------------------------------------------+             +-------------------------------------------+
| | | |
| +----------+ | | +----------+ |
| | | | | | | |
| | | +----------+ | | | | |
| | | | | | | +----------+ | | |
| | | | | | | | | | | |
| | client | | request | serialize | | deserialize | | | server | |
| | +---> object +------------------+--> bytes ---+-------------->| request | | | |
| | | | | | | | object +---> | |
| | | +----------+ | | | | | | |
| | | | | +----------+ | | |
| | | +----------+ | | | | |
| | | | | | | +----------+ | | |
| | | | response | deserialize | | serialize | | | | |
| | <---+ object <------------------+--- bytes <--+---------------+ response | | | |
| | | | | | | | object <---+ | |
| | logic | +----------+ | | | | | logic | |
| | | | | +----------+ | | |
| +----------+ | | +----------+ |
| | | |
+----------------------------------------------+ +-------------------------------------------+

总的来说,Client 和 Server 会约定如下内容:

  • 内存对象 序列化/反序列化 的方式
  • 内存对象(业务参数)本身的内容

[BAD CASE] 接口文档缺失、七零八落,没有统一的规范(错误码约定等)

数据序列化/反序列化方式

内存对象的序列化/反序列化方式常见的有:

常见方案序列化/反序列化协议
grpc/thrift自定义算法
GraphQLgraphQL 协议
RESTful大多数场景下是 JSON,也可以是XML或者其他的

其实 RESTful 不算一个协议,而是一个设计风格。 REST 是基于 HTTP的,Representational State Transfer 翻译过来是 表征 状态 转移。

以阅读一篇文章为例来理解 表状态转移:

  • Resource:就是一个超文本,比如一篇文章(一个文本、一段声音 都算)
  • Representation:就是这个超文本的表现形式。比如一篇文章是HTML格式的,还是PDF格式的。一个资源可以有多个表征
  • State:相对当前资源的状态。比如(相对于)当前文章的下一篇文章
  • Transfer:从阅读当前文章变为阅读下一篇文章

具体的风格规则可以参考这篇翻译

RESTful 其实是面向资源的编程思想,在做CURD时很适合。但是不适合真正复杂的业务逻辑。

[BAD CASE] 在api定义时没有遵循RESTful的风格,包括:

  • url pattern 不统一。有的是驼峰命名,有的是全小写下换线,有点是两者兼有
  • 没有按层级的概念命名。比如整个项目其实有两大类API,一个是供产品线管理员使用的,查询产品线的资源;一类是供系统管理员使用的,可以查询所有的产品线的资源。当前没有讲两类分清楚。

[BAD CASE] 参数解析没有统一处理。序列化和校验有好几种方案,返回数据也是。

业务逻辑路由

使用 RESTful 风格组织API的话,一般会使用路由器。流行的路由类库有:

业务处理逻辑

Web后端开发一般都是针对某个具体的业务开发一个微服务。这里面业务逻辑开发包括两方面:

  • 技术相关:比如使用到了关系型数据库
  • 业务相关:比如是一个用户登录服务

技术只是手段,最重要的是对业务深度理解(对行业和技术有深入的洞察)。

业务建模

每个系统都不同,都需要认真的建模。这个也是业务开发人员的价值所在,不然真的成立CURDer。

[BAD CASE] 当前其实对业务没有很好的分析,大多数都是在面向数据库编程。

一个可能的业务业务建模为:

+-------+ +-----------+  +-----------+  +-----------+  +---------+
| | | | | | | | | mod a |
| | | | | | | | | |
| | | router | | balance | | protocol | +---------+
| | | | | | | |
| | | | | | | | +---------+
| | | (vip+ | | (cluster+ | |(certificate | mod b |
| | | | | | | | | |
| | | host+ | | gslb + | | | +---------+
| | | | | | | |
| auth | | routeRule)| | pool) | | | +---------+
| | | | | | | | | mod x.. |
| | | | | | | | | |
| | +-----------+ +-----------+ +-----------+ +---------+
| |
| |
| | +--------------------------+ +---------+ +-------------+
| | | | | | | |
| | | Area(idc + subregion...) | | product | |deploy_cluster |
| | | | | | | |
+-------+ +--------------------------+ +---------+ +-------------+

安全性

此处说的安全是指业务的鉴权/授权。

一般有两种方案:

  • 鉴权/授权交个对应的中间件做
  • 应用程序自己完成鉴权(可能调用第三方权限系统)

[BAD CASE] 内部调用API不做鉴权

[BAD CASE] 鉴权不全面,鉴权在各个API而不是集中在一个地方

技术相关

分层

分层算是对 设计原则 的一个具体的实践。

常规我们都会对代码进行分层。这个分层是工程上的分层,为了减少代码层面的混乱,降低复杂度、提高复用度和开发效率。

每个团队(甚至是公司)都应该有统一的分层,对于降低维护成本提高代码维护成本都有好处。

这是一个典型的API开发,技术上有比较成熟的方案。典型的分层方案为:

+--------------------------------+            +--------------------------------+
| | | |
| presentation/user interface | | View |
| | | |
+---------------+----------------+ +---------------+----------------+
| |
| +---------------v----------------+
| | |
| | Service |
| | |
+---------------v----------------+ +--------+---------------+-------+
| | | |
| business logic | | |
| | | +------------v-------+
+---------------+----------------+ | | |
| | | Manager |
| | | |
| | +------+--------+----+
+---------------v----------------+ | | |
| | +--------v---------v--+ |
| data access | | | |
| | | Dao | |
+---------------+----------------+ | | |
| +--------+------------+ |
| | |
+-----v----+ +-----v----+ +-----------v-----+
| database | | database | | 3rd service |
+----------+ +----------+ +-----------------+

三层分层 四层分层

三层分层:

  • 表示层:为用户提供交互界面,实现系统数据的输入和输出
  • 业务逻辑层:复杂关键业务逻辑的处理和数据传递
  • 数据访问层:访问数据库,实现增删改查等操作

四层分层多出来了Manager,是对Service的下沉,抽取通用Service。

[BAD CASE] 当前有分层,但是层之间的依赖很有问题。多出了很多 A2B的转换的包,表示层也和业务层共用相同的数据类型。

一般建议将错误码进行分类,比如前三位是模块错误码,后三位是细分错误码,帮助快速定位问题

[BAD CASE] 错误码比较随意

数据存储

一般会根据数据的不同,将数据存储到不同的中间件中:

  • 关系型数据库:支持ACID和SQL,一般是用来处理 OLTP
  • NoSQL数据库 (notonly SQL),泛指非关系型数据库,是对关系型数据库的补充。比如:
    • 键值数据库:Redis等
    • 列式数据库:海量数据存储和分析,比如Hbase等
    • 文档型数据库:MongoDB等
    • 时序数据库(TSDB):InfluxDB 等
  • NewSQL 数据库:对关系型数据库的升级,既可以OLTP,也可以OLAP。高可用、弹性扩缩容。

这里面也有很多老生常谈的话题,比如:

  • 使用redis当做缓存层减少对DB的压力
  • 按时间对数据进行冷热分离减少行数
  • 按业务逻辑分库分表减少行数

上面话题不展开,推荐阅读 《大型网站技术架构》李智慧

[BAD CASE] 使用MySQL存储时序数据库,导致磁盘和CPU都居高不下

关于数据库访问,一般都放入DAO层。该层应该只完成和数据库访问相关工作,业务相关逻辑都应该上移到业务层。

[BAD CASE] 在dao层做了很多业务相关的逻辑。

如果是关系型数据库,其实操作就是固定的五个,可以参考这个仓库

[BAD CASE] 过多的 GetOneByXxx,也把 insert 和 update 混合起来使用 save

超时控制

超时控制包括两个方面:

  • 一个请求如果没有在有限的时间内完成,就应该给客户端返回错误,并且中止自己的操作
  • 每个子过程都应该有超时控制,它们的总和不能大于请求总超时

[BAD CASE] 当前没有总超时控制,部分子过程没有超时控制

在go 里面,使用 context 来完成超时控制, 伪代码为:

func do(ctx context.Context) {
// 设置请求的超时时间为 1 秒钟
ctx, cancel := context.WithTimeout(ctx, time.Second)

// 启动一个 goroutine 来处理请求
go func(ctx context.Context) {
// 等待请求完成或者超时
select {
case <-time.After(time.Millisecond * 1200):
// 请求完成
fmt.Println("Request completed")
case <-ctx.Done():
// 请求超时或者被取消
fmt.Println("Request canceled or timed out")
}
}(ctx)

// 等待一段时间后取消请求
time.Sleep(time.Millisecond * 1500)
cancel()
}

而客户端也都可以通过传递context来控制超时,比如请求数据库或者发送http请求。

日志打印

日志应该包括两大类:

  • 程序相关的:比如程序启动了,停止了
  • 访问日志:一般可以用来统计QPS等

对于访问日志:

  • 请求日志自动打印
  • 一个请求只打印一条INFO级别的日志
    • 也有可能还会打印更多的日志内容,但是建议使用AddField 这种方案,追加到同一条访问日志中,而不是打印多条日志
  • 最多打印一条Error的错误日志
    • 当处理异常时自动打印
  • 日志和日志、日志和请求通过LogID关联起来

[BAD CASE] 参数错误也打印错误

[BAD CASE] 更多的信息没有 AddField 这种方法

技术相关总结

APIServer 开发是有非常成熟的套路的,开发工作应该按照业务需求选择合适的开发路径。传统的开发模式伪代码如下:

package router {
func RegisterBiz1Route(rt RouteTree) {
rt.With(TimeOutMiddlerWare)
rt.Add("GET", "/products/:pid/biz1s/:biz1id", control/biz1.GetByIDAction)
rt.Add("POST", "/products/:pid/biz1s", control/biz1.CreateAction)
}
}

package control/biz1 {
type Biz1Param struct {
ID *string `bind:"required" uri:"biz1id"`
}

type Biz1Data struct {
ID string
Name string
...
}

func GetByIDAction(ctx gin.Context) (succData any, failRsp Error) {
param, err := lib.BindAndValidate(ctx)
if err != nil {return nil, err}

bizData, err := service/biz1.GetOne(ctx Biz1ParamC2S(param))
if err != nil {return nil, err}

return biz1DataS2C(bizData), nil
}

func CreateAction(ctx gin.Context) (succData any, failRsp Error) {
...
}
}

package service/biz1 {
// TODO 查询和创建用的是同一个参数
type Biz1Param struct {
ID *string
}

type Biz1Data struct {
ID string
Name string

SubObject1 SubObject1
...
}

type IBiz1Dao interface {
GetOne(ctx context.Context, param *Biz1Param) (Biz1Data, err)
...
}

// 依赖注入完成初始化
var biz1DaoObj IBiz1Dao

func GetOne(ctx context.Context, p Biz1Param) (Biz1Data, error) {
// 添加超时
// TODO dao 并没有抽象出接口,需要评估是否需要
data, err := biz1DaoObj.GetOne(context.WithTimeout(ctx, 60ms), p)
if err != nil {
// 记录错误(不是新增一条,而是追加错误)
lib.AccessLogAddField(ctx, "topic1", err.Error())
return nil, err
}

// TODO 这里其实可以访问其他的存储介质,比如MySQL

return data, nil
}

func CreateOne(ctx context.Context, p Biz1Param) (Biz1Data, error) {
...
}
}

package dao/biz1 {
type Biz1TableParam struct {
ID *int `db:"id"`
Name *string `db:"name"`
Names []string `db:"names,in"`
}

type Biz1TableData struct {
ID int `db:"id"`
Name *tring `db:"name"`
}

func GetOne(ctx context.Context, param *service/biz1.Biz1Param) (service/biz1.Biz1Data, err) {
data, err := orm.QueryOne(paramS2D(param))
if err != nil {return nil, err}

return dataD2S(data), nil
}
}

运维

应用上线后要有相应的:

  • 监控
  • 报警

[BAD CASE] 监控项缺失

[BAD CASE] 核心报警项缺失

其他

其他TOPIC

本文没有讨论:

  • 服务高可用:不能单点
  • 服务高并发:限流、降级、熔断
  • 服务大规模:服务拆分
  • 等等其他话题

go框架

go流行的框架/类库有:

这些框架好多都有一个功能:自动生成代码。

参考

  • 《服务端开发 技术、方法与使用解决方案》 郭进
  • 《凤凰架构》 周志明
  • 《聊聊“架构”》 王概凯
  • 《大型网站技术架构》李智慧