文章目录
- 前言
- 基本原则
- 构建步骤
- API 实践
- 商品呈现
- 初始的设计
- 个性化,千人千面 & 可视化
- 超前的设计
- 监控
- 遗漏的监控
- 业务服务
- 效率是第一生产力
- 业务服务API样例
- 服务配置
- ClientInfo
- “用完即走”的业务服务
- 一个周末的辛劳==无数个喝咖啡的悠闲时光
- 总结
- 参考资料
前言
对于网站、APP的后端开发来说,不论做需求、做项目大部分时候都是在做API。本文从基本原则、构建步骤、API实践三个部分,由理论到实践阐述Web API方法论及其实践。
基本原则
首先来看一下开发Web API的一组基本原则,遵从如下原则能够在设计之初避免大部分的坑以及使得API更易于维护和扩展。
-
使用HTTPS协议。
-
API须有版本。
-
请求需要带上 requestId (traceId),方便日志排查。
-
返回合适的状态码,参见HTTP状态码。
-
使用一致的路径格式。
在HTTP中,路径使用全小写字母,属性采用小驼峰,中间不需要使用_或其它字符进行连接;
(在HSF接口中,包路径全部采用小写字母,接口名采用大驼峰命名;参数名称、参数中的key、返回值、返回中的key采用小驼峰进行命名。) -
按需返回结果集,减少嵌套。
-
针对时间戳,应该使用格式为ISO8601的UTC时间。
-
提供详细的错误信息,包括一个机器可读的ID、一段人类可读的错误消息以及一个可选的指向更多细节的URL。
-
幂等性。
-
应该对所有响应中的JSON进行压缩。
-
以机器可读的格式提供JSON schema。
-
为开发者准备详细的API文档。
-
API可以被方便的测试。
-
隔离的开发、预发、生产环境。
构建步骤
通过对文末的参考资料的学习以及对于日常工作流程的抽象,我把构建Web API分为如下五个步骤。每个步骤都包含几条不可或缺的要点;某些步骤可以迭代完成;遵循这些步骤让API变的易读、便于测试、可修改、高效、稳定。
1、设计
针对需求抽象出客户端跟服务端交互需要的所有元素。
列出方法、属性、参数等的命名,并尽可能符合通用的名称。
绘制状态图,记录服务提供的所有动作(状态变化)。
2、评审
创建一份供人类阅读的详尽、清晰的API文档,定义服务中使用的所有描述符:路径、接口名、参数、返回等。
跟客户端、服务器端、及测试人员分享该文档,在整个构建周期中都可以根据需要调整、变更这份文档。
按需选择一种协议类型,或者能够便捷的支持大多通用类型,比如HTTP JSONP、HSF、MTOP。
3、编码
编写代码及单元测试。
处理非功能性需求:安全、性能、扩展性、稳定性、监控告警等。
保持代码、文档、状态图的一致性,并在有必要时进行调整。
4、测试
步骤4跟步骤3交替进行,本步骤预先提供Mock的接口,或者Mock的字段、参数等供客户端功能开发。
待步骤3完成后,进行联调、集成测试、压力测试、兼容性测试等。
5、发布
发布你的API及文档,以便其他人可以用他们创建新的服务及客户端程序。
API 实践
遵从API设计的基本原则,然后通过不断使用如上五个构建步骤迭代式的构建API,使得你的API变的更快、更强、更好。
举两个实际应用的例子,第一个是商品呈现的API,用于展现营销类的商品列表;第二个是通用的业务服务API,为了快速响应业务需求而灵活、迅速的装配出服务。
商品呈现
初始的设计
为了满足业务对排序和商品抽取在呈现上的需求,做了如下初始设计:
通过对招商接收到的数据源构建索引,使得商品数据可以高效、多维度的查询。
区块配置管理中通过界面可动态定制不同业务不同展示区块的排序、查询等表达式。支持多业务展示需求。
排序组件通过配置先从搜索引擎中捞取商品元数据缓存,然后对商品进行打分并排序,最后将商品Id序列缓存到Tair。丰富可选的排序方式,提升转化。
状态和库存同步处理器通过接受IC消息或者批量获取两个方式,做到实时同步商品信息。以便下架的商品不展示或售罄商品沉底展示,提升流量的利用率。
最后从业务门户、活动页、无线端,一次请求进来后先读取对应区块的商品序列然后查询搜索引擎返回商品。这里使用本地缓存保障性能稳定,减轻搜索压力。
上面描述了商品呈现API初始的设计,继续来看一下API张什么样:
接口
https:// 域名 / {域名}/ 域名/{模块名}/ 类型 / {类型}/ 类型/{对象名}/[${方法}].htm
举例:
https://xxx.taobao.com/fantomas/json/items.htm
https://
域名
/
{域名}/
域名/{模块名}/
类型
/
{类型}/
类型/{对象名}/[${方法}].htm
举例:
https://xxx.taobao.com/fantomas/json/items.htm
参数
src : 来源标识appId : 应用IdblockId : 区块IdrequestId : 请求唯一标识,32位随机数,GUIDversion : 调用API的版本
分页参数…
一组业务参数…
src : 来源标识appId : 应用IdblockId : 区块IdrequestId : 请求唯一标识,32位随机数,GUIDversion : 调用API的版本
分页参数…
一组业务参数…
记录src请求来源标识,用于后续统计调用方访问量。
通过appId来隔离API返回的商品数据。
通过blockId来区分一个业务不同栏目不同活动页的商品。
appId + blockId 在这个架构里定义了一组商品数据。由于使用搜索引擎做商品查询基础,所以实际上这里就是定义了一个查询串。
请求带上requestId方便排查问题。
API需要带上version版本标识,以便能够对API做升级。版本号这里带在参数里,有另一种方式是带在url路径里,两种方式我更倾向于在参数里,更灵活。另外平滑升级API亦可不用去动到统一的版本,通过增加标识来解决,具体方式可继续阅读。
返回:
jsonp122(
{ success: true, ##执行状态 appId: "10", ##应用Id blockId: "1001", ##区块Id startRow: "0", ##起始行 pageSize: "100", ##pageSize totalItem: "4", ##总行数 data:
[
{ itemId: "111111111", ##商品Id,去detail页的连接请自行拼装 title: "什么什么什么什", ##商品标题 reservePrice: "98.10", ##商品原价 discountPrice: "18.70", ##商品折扣价 discount: "190", ##商品折扣*100的数值 activityPicUrl: "i2/1.jpg", ##商品活动图片相对路径 currentSellOut: "42", ##已售出件数:商品确认上线时30天售卖件数+参与活动时售出的件数 quantity: "501", ##原始库存 currentQuantity: "501", ##当前库存 isGoldSeller: false, ##是否金牌卖家 activityStartTime: "20140713100000", ##活动开始时间,格式yyyyMMddHHmmss activityEndTime: "20140716100000", ##活动结束时间,格式yyyyMMddHHmmss status: "1" ##当前状态: 1即将开始,2进行中,3已结束},
…… ##data为商品信息的数组]
}
)
jsonp122(
{ success: true, ##执行状态 appId: "10", ##应用Id blockId: "1001", ##区块Id startRow: "0", ##起始行 pageSize: "100", ##pageSize totalItem: "4", ##总行数 data:
[
{ itemId: "111111111", ##商品Id,去detail页的连接请自行拼装 title: "什么什么什么什", ##商品标题 reservePrice: "98.10", ##商品原价 discountPrice: "18.70", ##商品折扣价 discount: "190", ##商品折扣*100的数值 activityPicUrl: "i2/1.jpg", ##商品活动图片相对路径 currentSellOut: "42", ##已售出件数:商品确认上线时30天售卖件数+参与活动时售出的件数 quantity: "501", ##原始库存 currentQuantity: "501", ##当前库存 isGoldSeller: false, ##是否金牌卖家 activityStartTime: "20140713100000", ##活动开始时间,格式yyyyMMddHHmmss activityEndTime: "20140716100000", ##活动结束时间,格式yyyyMMddHHmmss status: "1" ##当前状态: 1即将开始,2进行中,3已结束},
…… ##data为商品信息的数组]
}
)
个性化,千人千面 & 可视化
在无线化到来之后,业务面临了诸多问题,屏幕尺寸小、手机网络环境弱、在手机端的转化比PC低:业务PC时有6-7%转化但在无线端只有2-3%、由于要加载多块内容使得无线站点性能差、流量受到手淘位置变化影响,位置下滑导致流量骤减。
针对这些问题,从三个方面去解决:
通过商品呈现个性化,解决尺寸小、转化低的问题。
通过请求合并输出,解决网络弱、性能差的问题。
通过入口图个性化,解决流量减少问题。
针对无线化带来这些问题,对商品呈现API做了升级:
中间部分抽象了一个查询流程引擎。
通过逻辑拆解,将一次查询分解到不同的节点中实现。
接入ISearch个性化查询、TPP推荐查询、入口图个性化等使得呈现最终的商品列表输出具备了个性化排序。提升业务指标。
以及之前的终搜商品查询(后面切换为OpenSearch)变成了高可用的保底节点,提高了系统稳定性。
左侧配置域有:节点配置、参数配置、流程配置等,通过可视化的拖拉拽方式,动态定制流程且变更实时生效,提升开发效率。
一个流程请求进来,支持多种协议调用,流程分发会通过流程Id参数路由到不同的流程,这里会从配置管理中获取流程的信息。
使用 本地缓存 + Tair LDB 双缓存机制,配置信息的获取都是在内存中完成,提升接口性能。
这个接口承载了一天近3亿次请求,峰值QPS1.5w,通过做了监控,开关等,以及实时的保底逻辑,保证稳定性。
在这个设计里,引入了flowId来区分不同业务场景下的查询需求,返回的结果也不局限于单列表的商品数据。这样的好处是能够对不同业务场景做小范围平滑升级,代替了版本号。
可视化配置图例:
超前的设计
随着越来越多的业务场景接入,以及不同业务场景获取数据的复合性的增加,考虑将可视化商品呈现API做并行化的升级。这个设计的思路来源于之前在安全部跟刘强一起开发的事件流引擎。下面来看一下可视化并行流程引擎的设计:
核心的部分是下图中间区域的解图流程。通过将一个查询流配置为一个DAG图(有向无环图,最近在看的Spark也用到类似机制),将可以并行的部分(如下部分流程图中的b节点和c节点)提交到线程池,通过完成服务最终汇总结果并返回,完成查询流的响应。
Visual Config可视化配置提供一个可拖拽的操作界面,在上一版可视化设计中已经完成串行流程图的读写逻辑,这里只需要在原有基础上扩展DAG图的读写及校验(连通性、有向无环)即可。
当一次请求进入后,通过flowId定位到该业务场景配置的图,加载并执行图,当线程池繁忙时主线程将转为执行图的串行版本。
由于目前前端已有解决方案,可以类似于PC端页面一样少量并行多个请求,这样后端的合并请求、并行化处理变得不是主要的性能点,于是这个设计停留在Demo阶段未实际使用。
监控
API的监控告警采用了系统输出日志,Alimonitor监控关键字、统计告警的方式。由于不是偏业务点的监控,这里没有使用Xflush。
在每个查询流执行结束后,会对data数据做判断,看是否正常输出了数据,如果数据为空则告警。
由于有些流程节点的功能性不同,可能查询结果非通常商品类数据,data数据集合始终为空的,会做一个开关项,根据flowId排除此类流程的关键字日志输出,避免无效告警。
另外对于请求量、业务指标转化等的监控,通过EagleEye、A+、uData及BI的数据报表来做监控。
对于前端,通过做隐藏保底,保证页面主体正常展示或降级展示。之前有考虑通过第三方主动的校验请求,校验页面Dom的方式验证页面正常性,但由于页面变化较为频繁,这个策略没有落地。
遗漏的监控
通过上面的监控可以及时的处理空窗情况,避免引发故障。
但随着业务、来源、流程、页面区块的增多,对于请求更为细粒度的监控比较难于去把握到。比如我期望查看到某个业务或者某个流程的小时、日请求量等就没办法通过EagleEye直观的看到,因为接口收敛到一个。
这个部分需要自身处理,而无法通过通用监控实现。策略上面可以有两个方式:
通过日志采集框架,如TT等,将访问日志准实时采集并集中,然后通过云梯统计回流db,产出报表。
在业务接口上,通过业务、来源(比如我是合作方展示某个业务的商品数据,来源用于标识合作方应用、业务用于标识数据本身数据属于哪个业务)、流程、区块等维度,做多个原子统计值,起后台线程定时且原子性的刷入db做累加,如每小时集群N台机器都刷同一份小时数据,日数据可以出报表时做db的count来计算。
业务服务
当时所构建的系统,除了通过API提供商品呈现服务外,整个体系是一个小的闭环,是相对封闭的。通过调用外部服务,数据的流动及处理,自制的流程化机制来满足业务需求。给出的是完整的方案,而各个部分的能力是不对外的。
当时做服务的契机是需要这套系统能够快速承接更多的业务,而这些业务本身有一套系统在支持,由于侧重点不同,部分需求期望通过这套系统来提供。那么通过开放服务能够快速接入这些业务,将自身的能力对外输出,也能在支撑不同业务的过程中完善自身的功能。
效率是第一生产力
开始设计业务服务的时候,用基本的方式,拆分业务对象,封装业务逻辑,通过HSF逐一暴露接口。
在实际开发过程中发现针对灵活多变的业务接入场景,服务设计很难一次做到通用。
一切以效率出发,我设计了这个业务服务框架:
通过引入动态语言的灵活特性构建一个高可配置的服务框架,加上在线编写业务逻辑,即可方便的暴露服务。
配置管理后台有ACL等权限控制,且无VIP,不对外暴露,保证其代码安全性。
减少中间环节耗时,实时在线调整,可以做到无需编译部署就能直接发布服务,极大减轻了开发、联调、测试等的成本。
针对不同的业务场景方便个性化定制。
加载服务通过本地缓存->Tair->DB的方式确保脚本执行无需重复加载,提升执行性能。
零依赖的client jar,让客户端引用不必陷入jar hell的困扰。统一的单服务配置,只需调整参数(应用Id、服务名称、服务版本)即可调用不同服务。
业务服务API样例
POM依赖
<dependency>
<groupId>com.taobao.${projectname}</groupId>
<artifactId>${projectname}-api</artifactId>
<version>1.0.0</version> </dependency>
<dependency>
<groupId>com.taobao.${projectname}</groupId>
<artifactId>${projectname}-api</artifactId>
<version>1.0.0</version>
</dependency>
服务配置
bean id 酌情调整以免重复。
<bean id="servicesRemote" class="com.taobao.hsf.app.spring.util.HSFSpringConsumerBean" init-method="init">
<property name="interfaceName" value="com.taobao.${projectname}.Services" /> <!-- 日常:1.0.0.daily 预发&线上:1.0.0 -->
<property name="version" value="${version}" /> </bean>
<bean id="servicesRemote" class="com.taobao.hsf.app.spring.util.HSFSpringConsumerBean" init-method="init">
<property name="interfaceName" value="com.taobao.${projectname}.Services" /> <!-- 日常:1.0.0.daily 预发&线上:1.0.0 -->
<property name="version" value="${version}" />
</bean>
ClientInfo
类型 Key | 描述 |
---|---|
Integer appId | 特卖:1;抢购:6 |
String clientName | 真实调用方的名称 |
String requestId | UUID.randomUUID().toString(),去掉’-’ |
Long timestamp | System.currentTimeMillis() |
Long userId | 前台操作为卖家、买家Id,后台操作为小二BUC中的工号 |
Integer version | 如无个性化需求都为1,多个版本可共存 |
String hostName | 客户端主机名,InetAddress.getLocalHost().getHostName() |
分配给业务的 appId & clientName
Integer appId = 10String clientName = “taojinbi”
Integer appId = 10String clientName = “taojinbi”
获取商品列表接口
应用ID:10服务名称:Item.queryItems版本:1
应用ID:10服务名称:Item.queryItems版本:1
参数
类型 Key | 描述 |
---|---|
Long activityId | 业务活动Id |
Integer status | 商品状态 |
Integer manualCheckMark | 人工审核勾选标记 |
Integer page | 当前页码,从1开始 |
Integer pageSize | 一页行数,取值范围[1,100] |
返回
类型 Key | 描述 |
---|---|
Boolean success | 请求成功处理,并返回 |
Integer code | 结果码,以HTTP状态码为基础扩展 |
String summary | 信息摘要,简短纯英文描述结果状态 |
String message | 信息完整内容,人可阅读的成功或错误信息 |
Map<String, Object> data | 返回商品列表以及分页参数 |
测试脚本
import com.taobao.zhi.domain.ClientInfo
import com.taobao.zhi.utils.ClientInfoUtil
import com.taobao.zhi.helper.ItemHelper
import static org.junit.Assert.*
def servicesRemote = context.getBean("servicesRemote") // build clientinfoInteger appId = 10String clientName = "taojinbi"Long ANONYMOUS_USERID = 0L
ClientInfo clientInfo = ClientInfoUtil.buildClientInfo(appId, clientName, ANONYMOUS_USERID) // set serviceNamedef serviceName = "Item.queryItems" // build paramsMap<String, Object> params = new HashMap<String, Object>()params.put("activityId", 10000L)params.put("status", ItemHelper.ItemStatus.TO_CHECK.getCode())params.put("manualCheckMark", ItemHelper.ManualCheckMark.CHECKED.getCode())params.put("page", 0)params.put("pageSize", 100) // call servicedef total = 0List<Map<String, Object>> items = new ArrayList<Map<String, Object>>()while (total >= params.get("page") * params.get("pageSize")) {
def page = params.get("page")
page++params.put("page", page)
def result = servicesRemote.execute(clientInfo, serviceName, params) // 判断是否执行成功if (result.isSuccess()) { // 解析返回结果Map<String, Object> data = result.getData()
data.get(ItemHelper.PAGE)
data.get(ItemHelper.PAGE_SIZE)
total = (Integer) data.get(ItemHelper.TOTAL)items.addAll((List<Map<String, Object>>) data.get(ItemHelper.ITEMS))
} else { // 当请求处理失败时记录日志的方式log.error(result.getLogMessage(clientInfo))
}
}if (items.size() > 0) {
assertEquals(items.get(0).get(ItemHelper.ACTIVITY_ID), 1000076L)
assertNotNull(items.get(0).get(ItemHelper.ITEM_ID))
assertNotNull(items.get(0).get(ItemHelper.TITLE))
assertNotNull(items.get(0).get(ItemHelper.RESERVE_PRICE))
assertNotNull(items.get(0).get(ItemHelper.DISCOUNT_PRICE))
assertNotNull(items.get(0).get(ItemHelper.ACTIVITY_PIC_URL))
assertNotNull(items.get(0).get(ItemHelper.QUANTITY))
assertNotNull(items.get(0).get(ItemHelper.SELLER_ID))
assertNotNull(items.get(0).get(ItemHelper.GMT_CREATE))
assertNotNull(items.get(0).get(ItemHelper.ACTIVITY_START_TIME))
assertNotNull(items.get(0).get(ItemHelper.AUDITOR))
assertNotNull(items.get(0).get(ItemHelper.TB_CAT_ID1))items.get(0).get(ItemHelper.TB_CAT_ID2)items.get(0).get(ItemHelper.TB_CAT_ID3)items.get(0).get(ItemHelper.TB_CAT_ID4)
}
assertEquals(items.size(), total)return total
import com.taobao.zhi.domain.ClientInfo
import com.taobao.zhi.utils.ClientInfoUtil
import com.taobao.zhi.helper.ItemHelper
import static org.junit.Assert.*
def servicesRemote = context.getBean("servicesRemote") // build clientinfoInteger appId = 10String clientName = "taojinbi"Long ANONYMOUS_USERID = 0L
ClientInfo clientInfo = ClientInfoUtil.buildClientInfo(appId, clientName, ANONYMOUS_USERID) // set serviceNamedef serviceName = "Item.queryItems" // build paramsMap<String, Object> params = new HashMap<String, Object>()params.put("activityId", 10000L)params.put("status", ItemHelper.ItemStatus.TO_CHECK.getCode())params.put("manualCheckMark", ItemHelper.ManualCheckMark.CHECKED.getCode())params.put("page", 0)params.put("pageSize", 100) // call servicedef total = 0List<Map<String, Object>> items = new ArrayList<Map<String, Object>>()while (total >= params.get("page") * params.get("pageSize")) {
def page = params.get("page")
page++params.put("page", page)
def result = servicesRemote.execute(clientInfo, serviceName, params) // 判断是否执行成功if (result.isSuccess()) { // 解析返回结果Map<String, Object> data = result.getData()
data.get(ItemHelper.PAGE)
data.get(ItemHelper.PAGE_SIZE)
total = (Integer) data.get(ItemHelper.TOTAL)items.addAll((List<Map<String, Object>>) data.get(ItemHelper.ITEMS))
} else { // 当请求处理失败时记录日志的方式log.error(result.getLogMessage(clientInfo))
}
}if (items.size() > 0) {
assertEquals(items.get(0).get(ItemHelper.ACTIVITY_ID), 1000076L)
assertNotNull(items.get(0).get(ItemHelper.ITEM_ID))
assertNotNull(items.get(0).get(ItemHelper.TITLE))
assertNotNull(items.get(0).get(ItemHelper.RESERVE_PRICE))
assertNotNull(items.get(0).get(ItemHelper.DISCOUNT_PRICE))
assertNotNull(items.get(0).get(ItemHelper.ACTIVITY_PIC_URL))
assertNotNull(items.get(0).get(ItemHelper.QUANTITY))
assertNotNull(items.get(0).get(ItemHelper.SELLER_ID))
assertNotNull(items.get(0).get(ItemHelper.GMT_CREATE))
assertNotNull(items.get(0).get(ItemHelper.ACTIVITY_START_TIME))
assertNotNull(items.get(0).get(ItemHelper.AUDITOR))
assertNotNull(items.get(0).get(ItemHelper.TB_CAT_ID1))items.get(0).get(ItemHelper.TB_CAT_ID2)items.get(0).get(ItemHelper.TB_CAT_ID3)items.get(0).get(ItemHelper.TB_CAT_ID4)
}
assertEquals(items.size(), total)return total
可以看到业务服务的API文档与商品呈现API会有一些差异,也会有一些统一的部分,比如都有一个类似ClientInfo的参数,用于统一传递请求的业务Id、来源、请求唯一标识(HSF可用EagleEye的TraceId)、调用时间戳、IP等信息。
业务服务由于使用Groovy动态语言,使得编写测试也可以通过它来完成。所以API文档本身附带了测试用例,一面可用于接口的集成测试,一面也作为客户端调用服务的样例代码。
下面简单看一下业务服务框架的界面截图。
首先是服务的管理界面,提供服务编辑、复制编辑、服务缓存清理,服务实时测试等功能。
然后是服务的创建或修改界面:录入一段Groovy代码保存即可开放一个新服务。
“用完即走”的业务服务
一种语言无法编写所有的软件;一种架构无法满足所有业务场景。
最适合的就是最好的。
在设计业务服务之初,我经历过为了满足业务需求,做快糙猛的项目。代码常常跟业务的生命周期一样,那样的短暂。
如果你跟我一样,视代码如己出,看着它下线会感到悲伤的话,那么就能理解为什么在这个场景下我会这样设计。
框架是持久存在的,流失的是代码片段。
呼之即来,挥之即去。
业务下线,删了便是;
需要针对业务定制化,复制一个服务加点小逻辑也无妨;
升级的时候,再来一份,升级个版本,新老共存,version1、2、3…n,客户端只需改个版本参数即可。
做一个洒脱的,“用完即走”的业务服务。
一个周末的辛劳==无数个喝咖啡的悠闲时光
在做这个业务服务框架之前,我花了大概3天的时间,为了提供外部业务接入而构造了若干个服务。当我想要做这个小框架到完成这个框架大概花了一个周末的时间,随后花了3小时实现了之前花3天开发的业务功能。
在这之后,又花了些空闲时间来补充了一些周边的功能,比如测试、复制编辑等。小伙伴也帮忙增加了隔离环境发布、修改比较等功能。
增加的feature不仅完善了体验,也让工作更有效率。
业务服务框架的用途和价值
开发效率提升:实时发布、多服务版本共存、客户端零依赖等特性,使得系统对接成本降低,开发效率提升–开发联调时间节省50%。
多种业务的支撑。
作为运营、开发工具:校验,大促等场景的支持。
总结
API设计看似简单,要想设计好还是满难的。遵循软件设计高内聚、低耦合的哲学,将细节尽可能收敛到API内部,给客户端一个简洁一致的接口。可以通过可视化、流程化的快速配置、复用,或者动态语言的灵活性来提升开发效率、系统稳定性等,以便应对高速变化的业务需求。
参考资料
《架构风格与基于网络的软件架构设计》
《Web API设计方法论》
《HTTP API设计指南》
《JSON风格指南》
《DXP API设计规范》
《RESTful Web Services Cookbook》