优雅的 API 是清晰简洁的, 就像少女的肌肤一样柔滑.
背景
API 是软件应用向外部提供自身服务的一种形态和公开接口. 就像一个人的着装打扮, 举止言行, 形象状态, 是其内在的某种体现. 很少有人能看到对方灵魂的内涵, 但通过公共接口, 可以略窥一二.
以前缺乏 API 设计的意识, 没有经过仔细的思考, 做出来的 API 够用, 但比较粗糙. 如果要重新设计 API, 会是怎样呢? 本文以导出 API 为例, 试以阐释. 限于个人知识和经验, 若有不对之处, 欢迎指出 :)
好的 API
好的 API 应该是怎样的呢?
清晰简洁
参数少而精. 严格控制每一个参数的增加. 添加容易删除难.
最理想是一层平铺结构, 尽可能避免继承和嵌套(有些情况下例外);
若无法避免继承或嵌套, 不要超过两层.
不要混杂与使用无关的东西. 比如, 不暴露任何实现细节; 不暴露与功能无关的选项.
容易理解和使用
API 是给程序猿媛们使用的. 因此容易理解和使用, 也是建立在这个圈子, 而不是给小白用户. API 主要由接口签名, 参数, 返回值构成. 而参数和返回值, 都是包含三要素: 语义, 名称, 类型.
语义: 每个参数都必须有确定的语义. 避免语义不明的参数.
名称: 尽量选择简单的单个通用单词和约定俗成的词语, 望文知义. 避免使用四级以上词汇. 比如 维度 dimension, 来源 source, 业务类型 biz_type 都是可以接受的. 而 ValueSource, 虽然也没问题, 但含有两个单词; 选项用 options 而不是 choose ;
类型. 尽可能用确定的类型, 而不是包容性强的类型. 比如传值的列表, List 而不是 String. 序列化可以用框架搞定, 而不是手工解析或者额外写代码.
灵活强大
API 必须完成其使命. 如果 API 足够清晰简洁, 却不能完成所需要的功能服务, 那么是有缺失的. 要实现灵活强大的 API, 必须遵循正交与组合的古老法则.
容纳 80% 的常用场景, 但为所有场景留下空间. 比如通常只传一个订单类型, 但有时需要多个怎么办? 使用 List 而非单个.
参数语义可组合. 参数语义没有隐式的耦合, 可以灵活组合.
体验友好
体验友好, 有时与灵活强大是相矛盾的. 想想 GUI 和 CUI , 通常人们认为 GUI 的体验比 CUI 好得多, 只有程序猿知道, CUI 在功能和效率上比 GUI 胜过不知多少倍. 因此, 只能在两者之间做出合适的权衡. 不过, 仍然有一些办法, 可以保证灵活强大的基础上, 提供友好的使用体验.
使用的概念. 通常, 人们认为使用是指无意识的使用, 无师自通的学会使用. 实际上, 使用包含了隐式的学习过程. 使用与学习密切相关. 怎么让用户使用更友好, 某种意义上, 是怎样让用户更容易学会.
符合习惯. 参数命名与业界 API 保持一致性, 符合习惯, 更容易让程序猿媛上手.
工具方法. 如果有些参数 (比如扩展参数, 嵌套参数) 设置起来很费力不直观, 可以提供友好的工具类和工具方法, 让使用者更容易. 归根结底, 是让使用者更容易地学会自定义的方式和方法.
链式调用. 含有多个参数时, 可以提供链式调用, 让使用者写起来更流畅.
设计考量
设计好的 API, 需要考虑哪些因素呢?
核心. 考虑实现核心功能的必要参数. 没有这些参数, 就无法完成核心功能. 比如导出实现中, 必须先筛选出所需要的记录, 筛选条件参数就是必要参数.
扩展. 获得功能的定制化结果所需要的参数. 比如导出文件格式, 导出维度, 导出字段列表等.
外围. 为了更好地管理, 联调, 监控, 统计, 运维等. 比如调用源, 请求 ID, 业务类型, 导出 ID 等.
设计实例
下面, 以退款导出, 通用导出, 订单导出为例, 说明导出 API 的设计过程.
退款导出
从最简单着手
从最简单着手. 假设要做一个简单的退款导出, 只有一个调用方. API 应该是怎样的? 首先只从核心功能入手.
想象下, 外部需要关心什么? emmm ... 如果不需要搜索什么, 那么内部可以获取到所有信息, 自己搞定一切, API 可以是无参的!
当然, 现实没那么简单!
建立基础
假设有多个调用方. 通常需要使用基础参数, 来记录导出的调用方等.
需要标识是什么业务方来调用. 可以使用 source = 'xxx'. 目前为止, 一个就足够了. 不要给调用方添加任何多余的负担.
返回值呢? 通常采用约定俗成的方式. 会有一个 XXXResult 类标识是否成功, 错误码和错误消息之类. 为了避免阻塞, 导出一般采用异步的实现. 前端发送请求给后端, 后端给前端一个简单的响应. 待任务完成后, 再行后面的事情.
这样, 最简单的退款导出 API 如下所示:
清单一:
- public interface RefundExportService {
- BaseResult<String> export(RefundExportParam refundExportParam);
- }
- @Data
- public class RefundExportParam {
- /** 调用方 */
- private String source;
- }
搜索参数
通常, 退款需要先搜索出所需要的记录, 然后再根据这些记录额外获取其他信息, 再写入和上传报表. 现在, 需要加点东西了. 假设需要根据 退款编号和 退款时间来搜索. 那么, 需要在 RefundExportParam 里增加三个参数.
清单二:
- @Data
- class RefundExportParamV2 {
- /** 调用方 */
- private String source;
- /** 退款编号 */
- private String refundNo;
- /** 退款起始时间 */
- private Long startTime;
- /** 退款结束时间 */
- private Long endTime;
- }
到目前为止, 似乎一切都很自然. 假设退款还有更多搜索参数, 那么是不是全部都写在 RefundExportParam ? 实际上还有一种方案, 将搜索参数语义分离出来, 建立类 RefundSearchParam .
清单三:
- @Data
- public class RefundExportParamV3 {
- /** 调用方 */
- private String source;
- /** 退款搜索参数 */
- private RefundSearchParam search;
- }
- @Data
- class RefundSearchParam {
- /** 退款编号 */
- private String refundNo;
- /** 退款起始时间 */
- private Long startTime;
- /** 退款结束时间 */
- private Long endTime;
- }
设计选择
现在, 需要做出一个选择. 究竟是清单二的方式好, 还是清单三的方式好呢?
从清晰简洁来看, 无疑清单二是非常简单符合标准的; 而清单三增加了嵌套, 增加了复杂性. 仔细分析 RefundExportParamV2 , 会发现这个参数含有两层语义: 1. 用于搜索记录的语义; 2. 调用相关语义. 当这两层语义都比较多时, 就会导致这个类比较混杂. 此时, 有两种选择: 1. 如果按照清单二, 那么需要把这两种语义的参数用空行显式分割开; 2. 如果按照清单三, 则需要提供一些遍历方法, 让调用方更加友好地设置 search 及 search 里的参数, 提供更好的使用体验. 此外, RefundSearchParam 还可以在退款搜索中复用.
我个人倾向于使用清单三的方式. 语义分离, 是创造清晰性的一种方式. 但有时, 清晰性与简洁性并不是一个概念. 简洁性是指一目了然, 清晰性是指各就各位. 清单三做到了清晰性, 但并非足够简洁; 清单二, 做到了简洁, 却不足够清晰.
总结下: 通过三个类的组合 (RefundExportService, RefundExportParam, RefundSearchParam) , 建立了退款导出 API 的基本骨架. 退款导出的 API, 其实是很多导出 API 的典型表达.
通用导出
假设, 该应用现在要接入一个电子卡券导出. 电子卡券导出与退款导出的流程和实现基本类似, 但搜索参数不同.
我不希望再加个 VirtualTicketExportService, 而是希望做成一个通用导出服务, 这个导出可以容纳退款导出和电子卡券导出, 以及后续的各种导出. 现在, 清单三的方式显然胜出了. 因为如果按照清单二, 需要把电子卡券搜索入参也写到 RefundExportParamV2 中, 清晰性立即骤降, 且导致了参数混杂(退款导出调用方也能看到电子卡券的搜索参数).
现在, 在退款导出的基础上, 重新设计一个 ( ExportService, ExportParam, SearchParam ) . 在 ExportParam 里肯定要加个导出业务类型参数 bizType. 重写如下:
清单四:
- public interface ExportService {
- BaseResult<String> export(ExportParam exportParam);
- }
- @Data
- public class ExportParam {
- /** 调用方, 必传 */
- private String source;
- /** 导出业务类型, 必传 */
- private String bizType;
- /** 搜索参数, 必传 */
- private SearchParam search;
- }
- class SearchParam {
- // How to design ?
- }
通用搜索入参
重点在 SearchParam 参数的设计. 先思考下 SearchParam 可能有哪些类型的条件? 相等性比较(eq, neq), 不等性比较 (lt, gt, lte, gte), 集合包含 (in) , 范围判断 ( range) , 模糊匹配(match) , 否定判断 (not). 绝大多数搜索基本落在这个范围内.
我能想到的, 有三种方案:
将 SearchParam 设计成一个 Map[String, T or Object] ,value 是泛型或 Object 类型. 可以在 Map 里的 value 中塞入各种具体条件类型. 这样需要从 value 中解析出各种条件类型, 很容易出错, 且不直观.
将 SearchParam 设计成一个 Object , 使用业务方定义的业务 pojo 进行赋值; 在实现内部, 采用反射的方式来解析这个 Object , 得到搜索条件. 通常, 容易出错, 且不直观.
将 SearchParam 设计成一个复合条件 Condition , 详见 "设计模式之组合模式: 实现复合搜索条件构建" 提供工具类, 方便地构造 Condition , 或者将业务方自定义的 pojo 业务对象, 转换成 Condition . 这样, 兼顾灵活性和友好性. 唯一的不足是, 让使用方多写了一个方法调用.
清单五:
- @Data
- public class ExportParam {
- /** 调用方, 必传 */
- private String source;
- /** 导出业务类型, 必传 */
- private String bizType;
- /** 搜索参数, 必传 */
- private Condition search;
- }
这样是不是可以了? 想一想, 如果搜索里面有一些必传参数要进行强校验, 比如归属(店铺 ID), 起始时间等, 从 Condition 里解析出这些条件可是不容易哦. 最好抽离出来.
清单六:
- @Data
- public class ExportParam {
- /** 调用方, 必传 */
- private String source;
- /** 导出业务类型, 必传 */
- private String bizType;
- /** 搜索参数, 必传 */
- private SearchParam search;
- }
- @Data
- class SearchParam {
- /** 业务归属 ID, 必传 */
- private Long bizId;
- /** 搜索起始时间, 必传 */
- private Long startTime;
- private Long endTime;
- /** 扩展搜索入参, 可选 */
- private Condition condition;
- }
关于通用搜索入参, 如果读者有更好的方案, 欢迎提出~~
设计选择
清单五和清单六的搜索入参设计, 哪种更好呢? 清单五的方式更加统一, 但对必传参数支持不太友好, 解析逻辑会比较复杂; 清单六将搜索入参分为了必传和可选, 更容易判断, 但在形式上不如清单五那么统一, 在实现上, 也需要将必传参数和 condition 在内部做一个聚合.
我个人会倾向于清单六.
订单导出
现在, 来看订单导出. 如何将订单导出纳入到通用导出的范畴内?
退款导出只考虑一种形态, 即退款单导出. 订单导出可以有多种形态. 比如有通用的订单导出, 有分销采购单导出; 通用的订单导出又有标准报表导出和自定义报表导出, 自定义导出有订单维度的导出和商品维度的导出, 标准报表是订单与商品的混合维度的导出. 看来 bizType 有点不够用了.
语义分析
考虑通用的订单导出和分销采购单导出. 有两种方案:
只使用 bizType : 通用的订单导出用 bizType = 'default_order', 分销采购单导出用 bizType = 'fenxiao_order'. 这样倒无大碍, 不过要统计这两种导出时, 就要做解析和处理.
使用大类 bizType 和 细分 category . 两者都是 bizType = 'order' , 通用的 category = 'default' , 分销采购单的 category = 'fenxiao' . 这样, 无论是合并统计还是区分对待, 都更加清晰.
仔细思考下, bizType 是指什么语义? bizType = 'refund', 'order' , 有什么不同? 为什么要区分开? 退款单导出会有订单商品信息; 订单导出会有退款信息. 首先, 每个导出报表, 一定是围绕某个业务实体. 比如退款单, 订单, 电子卡券核销等. 那么这个业务实体的所属域和信息主维度的不同, 就区分出了不同的 bizType.
再看 category 是指什么语义? default, fenxiao ? 看上去, 有点勉强. 可能这个参数名称还不够贴切.
如何区分订单维度和商品维度的导出呢? 这个相对容易解决. 维度只是一个导出选项. 可以在 ExportParam 增加一个 options:Map 参数, 提供定制化的可以组合的导出选项. 导出选项有维度, 文件格式等. 这些选项参数如果直接放在 ExportParam , 会让这个类变得臃肿.
如何区分标准报表导出和自定义报表导出呢? 标准和自定义可能是多个导出选项的组合. 不适合放在 options 里; 同时, 标准和自定义可能适用于所有的业务类型和细分类, 是一个策略概念. 因此设置一个 strategy 参数. 这个 strategy 可以决定一些选项的组合设置.
现在, 梳理一下导出业务的语义层面: 业务类型 (bizType: order, refund, etc. ) - 细分类 (category: default, fenxiao, etc.) - 策略 (strategy: standard, customized ) - 选项 (options: dimension, format, etc. ) . 这些是否足够涵盖所有可能的导出.
清单七:
- @Data
- public class ExportParam {
- /** 调用方, 必传 */
- private String source;
- /** 导出业务类型, 必传 */
- private String bizType;
- /** 导出业务细分, 必传 */
- private String category;
- /** 导出策略, 默认 */
- private String strategy = "standard";
- /** 搜索参数, 必传 */
- private SearchParam search;
- /** 导出选项, 可用于定制化 */
- private Map<String, Object> options;
- }
由此可见, 决定 API 入参的准则中, 语义分析和归类是一个非常重要的考量因素. 当一个服务要接入多个业务类型时, 需要进行仔细的语义分析和分类.
其他考虑
扩展参数
通常, 需要扩展参数, 做一些核心功能之外的事情.
联调. 为了更好地联调, 通常会设计一个必传的 requestId .
运维. 如果导出因为偶然因素而失败怎么办? 可以设计一个 exportId , 针对该导出 ID 进行导出和修复; 假如要针对指定的一批业务号来导出怎么办? 可以设计一个 filePath 来存储这些要导出的业务号. 这两个都可以放在 options 里, 因为对使用方无影响, 无感知. 少一个入参, 少一分干扰.
监控与统计. 调用源 source 实际上是用来统计的, 并非必要参数. 监控与统计, 尽量依赖内部的状态, 而不是 API 参数.
额外信息. 比如导出操作人等. 可以设计一个 extra:Map 来放置这些信息.
清单八:
- @Data
- public class ExportParam {
- /** 调用方, 必传 */
- private String source;
- /** 导出业务类型, 必传 */
- private String bizType;
- /** 导出业务细分, 必传 */
- private String category;
- /** 导出策略, 默认 */
- private String strategy = "standard";
- /** 搜索参数, 必传 */
- private SearchParam search;
- /** 请求 ID, 必传 */
- private String requestId;
- /** 导出选项, 可用于定制化 */
- private Map<String, Object> options;
- /** 导出额外信息 */
- private Map<String, String> extra;
- }
注意:
扩展参数过多, 会导致必要参数不够凸显. 有一种办法是, 将这些扩展参数, 都放到一个 Map 里, 然后提供一些工具方法来设置. 比如 exportId, filePath 都放到 options 里. 少一个参数, 少一分干扰.
options 和 extra 虽然都是 map , 但作用是不一样的. options 会影响导出结果, 而 extra 不会. 因此, 必须将两者的语义分开.
requestId 虽然和 exportId 一样, 但 API 惯例是将 requestId 作为一个独立参数.
REST 传参
Condition 参数是一个接口. 对于 REST 传参 是不够友好的. 因为无法序列化. 这就面临一个尴尬的境地: 要非常灵活的搜索, 使用 Condition 和 Dubbo 接口; 可是总要面对一些 Node.JS 调用和 HTTP 调用, 需要支持 REST . 一种折衷的办法是, 提供一个 String 参数以及 DSL 工具, 让业务方通过 DSL 工具来构建查询字符串, 然后通过工具类解析这个字符串得到 Condition. 要不要保留 Condition 这个参数呢? 读者可一思.
清单九:
- @Data
- class SearchParam {
- /** 业务归属 ID, 必传 */
- private Long bizId;
- /** 搜索起始时间, 必传 */
- private Long startTime;
- private Long endTime;
- /** 扩展搜索入参, 可选 */
- private Condition condition;
- /** 扩展搜索入参, 供 REST 调用, DSL 查询构建见 ... */
- private String restCondition;
- }
小结
API 是软件应用向外部提供自身服务的一种形态和公开接口. 就像一个人的着装打扮, 举止言行, 形象状态, 是其内在的某种体现. 本文通过导出 API 的设计, 讨论了设计 API 需要考虑的一些因素和选择. 读者不妨针对自己工作中所遇到和学到的 API, 也做类似的思维体操, 相信是很有裨益的.
来源: https://www.cnblogs.com/lovesqcc/p/10224011.html