API 接口开发之道

还在为写接口而绞尽脑汁吗?看着千篇一律的接口文档和测试报告,是不是觉得开发 API 简直无聊又枯燥?别急,今天我将颠覆你对接口开发的认知,带你体验真正高效、优雅、专业的 API 接口开发之道

API 接口开发到底该如何做才能又快又好?如何在满足业务需求的同时,兼顾性能、安全性和易维护性?如果你也有这样的困惑,那这篇文章就是为你量身打造的!

随着互联网的发展,API 接口已成为企业技术能力的核心体现。无论是银行、保险、电商,还是社交、娱乐,每一个行业都离不开 API 的支撑。开发者不仅要会写代码,还需要了解业务逻辑、性能优化、安全防护等全方位知识。对于程序员来说,这既是挑战,也是通向高薪的必备技能。

01 什么是接口

接口其实是一种规范,在生活中随处可见,比如:不同厂商的水管使用统一的水管接口对接、电脑厂商和配件厂商按照统一的 USB 接口标准进行生产完成配对、应用程序之间通过 API 进行信息交互。

亚马逊集团创始人贝佐斯规定,亚马逊的所有系统、团队之间的交互,都必须通过服务接口,现在云计算技术非常火爆,其起源实际上是亚马逊的这种 API 文化,可以说,API 构建了当前数字化世界的通路。

图片

翻译过来就是:

  1. 所有的团队必须通过服务来对外暴露数据和功能。

  2. 所有的团队之间的功能,必须通过这些服务接口来进行交互。

  3. 网络上不允许任何除服务接口的其它交互方式。(不能通过 Excel 等文件传输)

  4. 不管具体使用任何的技术,都必须符合上面的3条原则

  5. 所有的服务接口必须都被外部化。

  6. 任何不遵循这个指令的人将会被解雇。

所以,软件世界中到处存在的 API,到底应该如何实现?有哪些设计技巧?接下来让我们一起探索吧。

02 接口设计

契约式设计原则

Design by Contract (DbC)是一种设计计算机软件的方法。这种方法要求软件设计者为软件组件定义正式的,精确的并且可验证的接口,这样,为传统的抽象数据类型又增加了先验条件、后验条件和不变式,可以说它是 API 设计的指导书,一个好的 API 实现之前,先要回答三个关键问题:

  1. API 期望什么?

  2. API 要保证什么?

  3. API 要保持不变的什么?

首先,API 必须保证输入是接收者期望的输入条件。比如:API 需要 A、B、C 三个参数,那么使用者要提前准备好这三个参数,当条件不满足时,会拒绝请求,当条件满足时,才会处理请求。

其次,API 要保证输出结果的正确性。API 在处理过程中遇到各种异常或错误,需要在内部做好处理,并最终将正确或期望的结果输出给使用者,而不应该把错误抛给其他系统去处理。

最后,API 必须要保证处理过程中的一致性。比如,同一个 API 被部署在10个服务器上,当输入条件发生改变时,所有服务器的内部的会话、状态、API 的输出也应该保持一致。

契约式设计原则作为指导思想,那么我们在设计 API 过程中,到底应该注意哪些细节?以下是我总结的六大设计原则:

   2.1 关注点分离

作为面向对象 SOLID 原则中的“I”,对我们的 API 接口提供了非常有实战意义的指导价值,所以当你在设计一个 API 的时候,不要在一个接口中杂糅多种功能,更不要用很多个 Flag 联动多个入参,这样只会让你的接口变得异常混乱。举个例子,客户只需要一个 USB 接口,而你却给了他一个交换机,他怎么知道那根线插哪里?要插多少根?排列组合都有 N 种,客户直接原地崩溃。所以说,我们在设计接口时,要力争减少使用者的认知负担,提供单一纯粹的功能。

// bad 
type UserService interface {
    CreateUser(name string, age int) error
    GetUser(id int) (User, error)
    UpdateUser(id int, name string, age int) error
    DeleteUser(id int) error
    SendEmail(userID int, message string) error // 发送邮件的职责与用户管理不相关
}

// good
type UserService interface {
    CreateUser(name string, age int) error
    GetUser(id int) (User, error)
    UpdateUser(id int, name string, age int) error
    DeleteUser(id int) error
}

type EmailService interface {
    SendEmail(userID int, message string) error
}

   2.2 自我表达

一个好的接口,客户在拿到后能很简单的读懂,在开发联调过程中,能根据错误信息自己修正,那么这个接口就具备了良好的自我表达能力,你可以从以下两大方面去考虑:

接口命名

接口命名应该给使用者提供一种简单且直观的体验,从接口名即可了解此接口的具体功能。

使用被大家接受的通用缩写,如:Information->info,delete->del,message->msg 等。

通过命名含义描述接口,比如获取用户列表:GetUsers。注意此方式适用于 RPC 协议接口风格,RESTful 风格不使用此形式,REST 使用名词 URI+动词形式。

错误信息

具有自表达的错误信息字段,且应该包含两点内容:

A:明确的错误指示。例如:“amount金额字段格式有误”。

B:错误后的解决方案。例如:“token字段缺失,请先登录”。

错误码设计

A:屏蔽内部逻辑。不要轻易暴露内部处理逻辑规律,防止接口被攻击。

B:便于快速定位问题。

错误码定义可以参考 HTTP 状态码,采用分段式,形成“肌肉记忆”,方便定位问题,也易于扩展。

分段 分段描述
1XX 信息,服务器收到请求,需要请求者继续执行操作
2XX 成功,操作被成功接收并处理
3XX 重定向,需要进一步操作以完成请求
4XX 客户端错误,请求包含语法错误或无法完成请求
5XX 服务器错误,服务器在处理请求过程中发生了错误

 

   2.3 互斥穷举

接口之间应该尽量遵守MECE(互斥穷举)原则,不应该提供相互叠加的接口。例如:如果 PUT /orders/1/items/1 接口用于修改订单项,那接口 PUT /orders/1 就不应该具备处理某个 order-item 的能力。

这样做的好处是不会存在功能重复的接口,造成维护和理解上的复杂性。如何做到完全穷举和彼此独立呢?

方案一:使用在线表格设计,标出每个URI的能力。

方案二:使用 DDD 领域建模,聚合根作为根 URL,实体作为二级 URI 设计。聚合根之间无任何联系,实体和聚合根之间责任明确。

举例:

聚合根和实体定义

Order 聚合根

  • 包含多个 OrderItem 实体

  • 负责管理订单的生命周期和相关操作

URI 动词 功能
/orders GET 获取所有订单
POST 创建新订单
/orders/1 GET 获取特定订单详情
PUT 更新特定订单
/orders/1/items GET 获取特定订单的所有订单项
/orders/1/items/1 GET 获取特定订单项详情
DELETE 删除特定订单项
PUT 更新特定订单项

   2.4 快速失败

接口要快速验证条件,不满足要返回失败,不要等到已经击穿到应用最底部后,再去报错。这是“防御式编程”的一种理念,一个具体的实践就是对于接口入参进行校验。golang 的 validation 插件案例:

// 枚举控制
enum OperaType {
  Get = 0;  // 获取值
  Set = 1;  // 设置值
}
OperaType opera_type = 2[(validate.rules).enum.defined_only = true]; // 操作类型


// 范围控制
int32 page_num = 3 [(validate.rules).int32.gte = 0]; //页码

给接口设置超时时间,异常情况快速失败,也能提高接口并发,但是会造成一些不可用,需要权衡利弊。

   2.5 幂等性

什么是幂等?简单来说,就是当一个操作多次执行所产生的影响均与一次执行的影响相同,则它是幂等的。现代服务逐渐往分布式架构发展,服务是分散的,如何保证并行调用的幂等?

以下是常见的几种方式:

使用唯一标识符

为每个请求生成一个唯一的标识符,并在服务端进行验证。如果服务端已经处理过该标识符对应的请求,则直接返回之前的结果,而不进行重复处理。

使用状态机

对于一些有明确状态的资源(如订单、支付等),可以通过状态机来确保操作的幂等性。每个状态转换只能发生一次,重复请求不会改变状态。

使用数据库唯一约束

在数据库层面使用唯一约束来防止重复数据插入。如果重复请求尝试插入相同的数据,数据库会抛出异常,服务端可以捕获并处理该异常。

使用乐观锁

我们在更新资源时检查版本号是否匹配。如果不匹配,说明资源已经被其他事务修改过,当前事务应该回滚。

使用悲观锁

悲观锁假设数据在大多数时间会发生冲突,读取数据时就加锁,防止其他事务修改数据,通常通过数据库的行级锁来实现,如使用“for update”。

使用分布式锁

分布式锁与悲观锁本质上相似,都通过串行化请求处理来实现幂等性。与悲观锁不同的是,分布式锁更轻量。在系统接收请求后,首先尝试获取分布式锁。如果成功获取锁,则执行业务逻辑;如果获取失败,则立即拒绝请求。

   2.6 版本化

一个对外开放的服务,极大的概率会发生变化。业务变化可能修改 API 参数或响应数据结构,以及资源之间的关系。一般来说,字段的增加不会影响旧的客户端运行。但是当存在一些破坏性修改时,就需要使用新的版本将数据导向到新的资源地址。

版本信息传输通常有以下几种方式:

URI 前缀控制

这是最常见和直接的版本化方法,通过在 URL 路径中包含版本号来区分不同版本的 API。

Header 控制

通过在 HTTP 请求头中包含版本信息来实现版本化。这种方法可以保持URL的简洁。

示例:

  • 使用自定义头:X-API-Version: 1

  • 使用 Accept 头:Accept: application/vnd.example.v1+json

Query 控制

通过在 URL 中添加查询参数来指定版本号。

总结

比较推荐的做法是使用 URI 前缀。常见的反模式是通过增加 URI 后缀来实现的,例如/users/1/updateV2。这样做的缺陷是版本信息侵入到业务逻辑中,对路由的统一管理带来不便。

使用 Header 和 Query 发送版本信息则较为相似,不同之处在于,使用 URI 前缀在 MVC 框架中实现相对简单,只需要定义好路由即可。使用 Header 和 Query 还需要编写额外的拦截器。

03 接口优化

   3.1 批量

示例:

  • Redis 单次查询使用 Mget,Pipline 等方式,批量查询。

  • 循环调用 RPC 接口耗时严重,优化 RPC 接口支持批量查询,替代循环调用。

本质上是减少不必要的网络 IO,但会对下游服务造成一定压力,需要综合考虑,不可滥用。

   3.2 异步

示例:

  • 用户请求接口中,需要做业务操作,发站内通知,可以同步执行操作写库,然后异步发通知。

区分核心和非核心操作,核心逻辑同步执行,非核心逻辑异步执行。通常异步方案有:线程/协程池,消息队列,任务调度框架,任务回调。

   3.3 并行

示例:

  • 两个操作任务,各耗时200ms,顺序调用需要花费400ms,改为协程调用,总花费只需要200ms。

将单个任务或串行任务拆分为多个子任务,利用多核处理器或分布式系统进行并行处理,提升处理效率。Golang 中可以使用 go 关键字轻松实现并行调用,本文不做赘述。

   3.4 空间换时间

示例:

  • 用户信息访问频繁,变更小,通过 redis 缓存用户信息,用内存空间来提升响应速度。

  • 代码中用列表存储要素,循环判断某个要素是否存在,改为使用字典存储,虽然空间占用变大,但是时间复杂度由O(n)降为O(1)。

  • MySQL 数据库额外存储一份索引数据也是一种空间换时间的方案,提升查找了速度。

空间换时间是一种常见的优化策略,通过额外的内存、磁盘等存储介质,减少程序的执行时间。

   3.5 池化

示例:

  • 通常我们在使用 MySQL 时,会采用池化技术,复用 MySQL 链接,来达到重复利用的效果,避免了不必要的创建和销毁所带来的性能损耗。

  • 在高并发场景下,Golang 使用协程池来实现协程的管理,提升程序性能,如经典的高性能协程池开源库 ants:https://github.com/panjf2000/ants。

通过预先创建一定数量的连接对象,当需要使用连接时,从池中获取一个可用的连接对象,使用完毕后归还给池,而不是每次都创建和销毁连接对象,从而提高系统性能和资源利用率。

   3.6 预处理

示例:

  • 用户打完王者荣耀对局后,通过客服入口举报违规玩家,我们通过预处理,调用各种数据接口和模型计算,并将信息存储下来,当工单流转到工作台时,接口可瞬时将信息拉取出来。

类似空间换时间的缓存思想,但是关注点不一样,我们可以将想要的数据提前计算好,放到缓存或数据库中,当真正需要的时候再进行取用,而不是实时计算,以此来提升接口性能。

   3.7 SQL 优化

接口开发过程中难免会和数据库打交道,但数据库非本文的介绍重点,不对数据库的具体优化方案进行详细介绍,只提供一些优化思路,感兴趣请自行查阅相关资料。

深分页

select * from t_records where uid = '{my_uid}' limit X, 30;

这是一常见的分页操作,看起来没啥问题,但是加入 X=5000000 时,数据库 server 调用 innodb 接口,在非主键索引中获取第0到第(5000000+30)条数据,返回给 server 之后,根据 offset=30,逐个抛弃,留下后面的30条,返回给业务。

优化方案:

// 基于主键或索引的优化
SELECT * FROM t_records WHERE id > last_seen_id ORDER BY id ASC LIMIT 30

// 使用子查询优化
SELECT * FROM t_records 
WHERE id IN (
  SELECT id FROM t_records WHERE uid = '{my_uid}' ORDER BY id LIMIT 100000, 30
) ORDER BY id;

// 延迟关联(Deferred Join)
SELECT a.* FROM t_records a
JOIN (
  SELECT id FROM t_records WHERE uid = '{my_uid}' ORDER BY id LIMIT 100000, 30
) b ON a.id = b.id;

// 反向分页 适用于查最新内容的场景
SELECT * FROM t_records ORDER BY id DESC LIMIT 30;

​​​​​​​

如果并发较高,可以考虑使用缓存的方案解决性能问题。

避免大事务

为了提高接口并发量,需要避免大事务。当需要更新多条数据时,避免一次性更新过多的数据。因为 update,delete 语句会对索引加锁,如果更新的记录数过多,会锁住太多的数据,由于执行时间较长,会严重限制数据库的并发量。因此,要考虑多个操作是否真的有必要放在一个事务中,将核心操作放到同一事务中,非核心操作在事务外面单独执行。

索引

加索引能大大提高数据查询效率,这个在接口设计之初也会考虑到,其中的一些索引设计和优化技巧,这里不做过多赘述。

分库分表

分库:是为了解决数据库连接资源不足问题,和磁盘 IO 的性能瓶颈问题。

分表:是为了解决单表数据量太大,sql 语句查询数据时,即使走了索引也非常耗时问题,此外还可以解决消耗 cpu 资源问题。

不过,分库分表的成本太高,非必要尽量不要采用。单表千万级也不一定要分,要看字段和索引的具体的存储情况而定。

归档历史数据

在设计数据库表之初,就要结合业务发展,考虑数据库量级增长趋势,考虑好归档方案,防止库表膨胀过大,对接口性能产生影响。

   3.8 锁粒度控制

为解决高并发场景下,多个线程同时修改数据时会造成数据不一致情况,通常我们会:加锁。但是如果锁加的不好,导致粒度太粗,也会影响接口性能,比如你要用卫生间,就不要把客厅门也锁上。

// 同步执行保存操作

func (f *FileOperations) DoSave(fileUrl string) {
  f.mu.Lock()
  defer f.mu.Unlock()
  // 创建目录
        f.mkdir()
    // 上传文件
  f.uploadFile(fileUrl)
        //发送消息
  f.sendMessage(fileUrl)
}

​​​​​​​

为了避免高并发场景下创建相同目录,第二次创建目录失败影响业务功能,我们加锁,但是也对上传文件和发送消息上锁,导致粒度太粗,影响了接口性能,降低了并发,好的做法是只对 mkdir 函数进行加锁,减小锁粒度。

   3.9 合适的存储

MySQL 换 Redis:当查询压力较大时,可以使用 redis 缓存数据。

Redis 换本地缓存:Redis 相比传统数据库速度要快很多,大概是毫秒级别,但是使用本地缓存的耗时可以忽略不计,而且从 redis 取数据会存在大量的序列化和反序列化过程,增加 CPU 负担。

Redis 换 Memcached:Redis 基于单线程,可能存在阻塞导致性能降低,但是 Memcached 是多线程模型,在某些高并发场景下,性能可能会更高。

MySQL 换 ES:后台系统假如需要复杂的查询,使用索引的查询场景比较灵活,使用 MySQL 可能就存在瓶颈了,可以考虑同步数据到 ES 中,提高查询检索性能。

MySQL 换 HBase:如果 MySQL 中的数据过于庞大,可以考虑将历史数据转存至 HBase 中,以提供高效的查询性能,通过设置合理的 RowKey 能进一步提高查询效率。

   3.10 压缩传输内容

示例:

  • protobuf 协议相较于 json 或 xml,有更高的性能是因为其采用 varints 编码算法进行了二进制数据压缩。

  • HTTP 协议中的一个头部字段 Content-Encoding,服务端可采用 Gzip、Deflate、Br 等算法压缩,提升传输效率。

04 接口安全

   4.1 加密

敏感数据在传输过程中可能被抓包,想要保证数据安全,需要对数据进行加密,常见的加密方式有对称加密和非对称加密:

对称加密:加密和解密过程中,使用相同的密钥,常见的对称加密算法有:DES、AES。优点是计算速度快,缺点为任一方密钥泄露都会导致信息不安全。

非对称加密:服务端生成一对密钥,私钥放在服务端,公钥发布给其他人使用,这种方式比较安全,但是速度会慢一些。

   4.2 加签验签

数据加签即由服务端用传输数据生产一串无法伪造的字符串,保证数据在传输过程中不被篡改。通常使用比较多的是 MD5 算法,将提交的数据通过某种方式组合,使用 MD5 生成加密串作为签名。当服务端接受请求时,就可以使用特定的组合方式生成签名进行对比,判断请求是否合法,此外可以将时间戳带入,增加超时校验,防止被抓包恶意攻击。

   4.3 token 授权

客户端输入用户名和密码,服务端校验成功后返回一个唯一值 Token 并缓存在服务端,后续客户端访问需要携带此 Token,服务端验证如果存在,则表明请求合法。同时我们要对 Token 设置合理的有效期,避免被抓包爬取数据。但是 Token 方案有缺点,比如:在分布式系统中,Token 方案依赖分布式缓存,会让我们的系统变得复杂。现在大部分系统会使用 JWT 来生成 Token,这种方式是无状态的,天然支持分布式。

   4.4 防重放

某些特定场合下要保证一个请求只能处理一次,如支付场景。这是我们可以使用 timestamp+nonce 方案,每次请求会生成 nonce 到集合中,服务端处理请求时会先判断该 nonce 是否在集合中,如果存在则为非法请求。永久保存 nonce 代价非常大,可以结合 timestamp 优化,比如超过 3min 认定为非法请求。

   4.5 限流

用户是真实用户,但是想频繁调用接口搞垮你的系统,或者调用量很大的情况下保证系统不宕机,这时我们可以使用限流方案来保护系统。常见的限流算法:令牌桶算法、漏桶算法、滑动窗口、计数器等。

   4.6 黑白名单机制

对用户进行分析,将恶意用户纳入黑名单,避免系统被频繁攻击。白名单类似,比如对某个用户开放特殊调用权限,通过加白实现。

   4.7 数据脱敏掩码

密码、手机号、身份证等敏感信息一般需要脱敏掩码展示,在保存时也不能明文存储,一般使用哈希算法进行加密,常见的如 SHA-256,也可加盐来增加安全性。

   4.8 防 SQL 注入

常见的 SQL 注入方式

基于字符串的 SQL 注入:' OR '1'='1',这种方式通常用于绕过登录验证。

基于联合查询的 SQL 注入:使用 UNION 关键字将恶意查询与原始语句结合,获取数据库敏感信息。如:' UNION SELECT username, password FROM user'

基于盲注的SQL注入:攻击者无法直接看到查询结果,通过观察响应来推断数据库信息。

基于堆叠查询的 SQL 注入:在一个语句中插入多个 SQL 语句,如:'; DROP TABLE users;'

防 SQL 注入应对方案

使用参数化查询:

  • 使用参数化查询(也称为预处理语句)可以有效防止 SQL 注入,因为参数化查询会将输入数据与 SQL 语句分开处理。

  • 在 Go 语言中,可以使用 database/sql 包的 Prepare 和 Exec 方法。

使用 ORM 框架:

  • 使用 ORM 框架(如 GORM)可以自动处理参数化查询,减少手动拼接 SQL 的风险。

输入验证和过滤:

  • 对用户输入进行严格的验证和过滤,拒绝包含特殊字符的输入。

  • 可以使用正则表达式来限制输入格式。

最小权限原则:

  • 数据库用户应仅具有执行所需操作的最小权限,防止在 SQL 注入成功后造成更大损害。

使用存储过程:

  • 将 SQL 逻辑封装在存储过程中,限制直接执行 SQL 语句的机会。

错误信息隐藏:

  • 在生产环境中隐藏详细的错误信息,防止攻击者获取数据库结构信息。

   4.9 权限控制

我们需要对用户进行权限校验,来保证接口能被正确的使用。一般有三种粒度,第一校验是否登录,第二校验接口功能权限,第三校验接口数据权限。通常内网系统,我们会使用 RBAC 来进行权限控制。

   4.10 使用 HTTPS

  • 数据加密:HTTPS 使用 SSL/TLS 协议对数据进行加密,确保数据在传输过程中的机密性和完整性,防止数据被窃取或篡改。

  • 身份验证:通过 SSL 证书验证服务器的身份,确保用户访问的是真实的网站,防止中间人攻击。

  • 完整性保护:确保数据在传输过程中不被篡改,如果数据被篡改,接收方可以检测到这种篡改,并拒绝接受这些数据。

05 接口维护

   5.1 文档

本人强烈反对单独使用文档维护接口,因为一旦文档和代码割裂只会逐渐缺失维护不再更新,要善于利用工具让代码和文档关联,自动更新。比如:开源的 swagger。

   5.2 日志

  1. 合理区分和使用 error、warn、info、debug、trace5 种日志级别。error 较严重,对业务有影响,warn 是警告,对业务影响不大,需要开发关注。

  2. 日志要打印方法、入参和出参,方便定位问题,携带关键的信息参数,如 userId,traceId 等关键信息。

  3. 统一使用合理的日志格式,包含基础的信息如:当前时间戳、日志级别、线程 ID 等。

  4. 遇到条件分支,如有必要,可以在分支首行打印日志,方便定位问题,理清逻辑走向。

  5. 使用异步方式输出日志,可以提升接口性能。

  6. 禁止在线上开启 DEBUG,可能会打满磁盘影响业务系统运行。

  7. 日志文件可以考虑单招等级分离,比如 Nginx 的 access.log 和 error.log。

  8. 核心功能模块建议打印完整的日志,方便快速定位问题。

06 总结

如果你正在学习 API 开发或者希望提升自己的接口设计水平,不妨试试 PostmanApifox 等工具,它们不仅能快速生成接口文档,还支持调试和测试,帮助你从初学者成长为 API 开发达人!

API 接口开发不仅仅是写代码,它涉及设计、测试、安全和性能等多个维度。真正优秀的开发者懂得以全局视角看待接口的每一个细节,确保它高效、安全且易维护。这,才是真正的 API 开发之道!

以上只是个人的一些总结,接口开发是一件比较复杂的事情,以上技巧仅供大家在写接口的时候作为参考。希望大家都能写出性能高、安全稳定易维护的好接口!完结撒花!

“API 开发不仅是一门技术,更是一种艺术!”