重新设计导出API
优雅的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 接口; 可是总要面对一些 NodeJS 调用和 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;
}
实用与清晰的平衡#####
在清单九中,condition 和 restCondition 双剑合璧,似乎无所不能。 可是,这种灵活性是否真的有必要呢? 是否引出了其他的问题?
首先,两个都在,总让人感觉有点冗余; 其次,要从抽象的 condition, restCondition 中解析出真实的条件,恐怕并不容易,尤其对于嵌套的条件;第三,实际上,导出并不需要如此灵活的搜索。 纵观各种导出,通常是在页面发起,搜索条件是“联合与”逻辑,而不会涉及或、否定、复杂嵌套的搜索。因此,导出所需要的搜索入参,只要满足联合与逻辑即可。
清单十
@Data
class SearchParam implements Serializable {
/** 业务归属ID,必传 */
private Long bizId;
/** 搜索起始时间,必传 */
private Long startTime;
private Long endTime;
/** 扩展搜索入参,可选 */
private List<Condition> conditions;
}
@Data
class Condition implements Serializable {
private static final long serialVersionUID = 7375091182172384776L;
/** ES 字段 */
private String fieldName;
/** 操作符 */
private Op op;
/** 参数值 */
private Object value;
/** 范围对象传参 */
private Range range;
/** 匹配对象传参 */
private Match match;
// 为了让JsonMap 能走通,必须有一个默认构造器
public Condition() {}
public Condition(String fieldName, Op op, Object value) {
this.fieldName = fieldName;
this.op = op;
this.value = value;
}
}
清单十中,搜索入参没有清单九那么灵活,但足够实用,并且更清晰简单,不冗余,达到了实用与清晰的平衡。
配置化#####
假设有一天,导出后台做到了足够的灵活性。只要来新的导出,配置下数据源、插件及顺序,就能解决,完全不用改动代码和发布系统,怎么支持新的导出呢?可以在 ExportParam 增加一个模板参数 templateId ,根据 templateId 拿到该导出业务对应的导出插件配置来实现导出任务流程。
小结###
API 是软件应用向外部提供自身服务的一种形态和公开接口。就像一个人的着装打扮、举止言行、形象状态,是其内在的某种体现。本文通过导出API的设计,讨论了设计API需要考虑的一些因素和选择。读者不妨针对自己工作中所遇到和学到的API,也做类似的思维体操,相信是很有裨益的。