重新设计导出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 还可以在退款搜索中复用。

我个人倾向于使用清单三的方式。语义分离,是创造清晰性的一种方式。但有时,清晰性与简洁性并不是一个概念。清单三做到了清晰性,但并非足够简洁;清单二,做到了简洁,却不足够清晰。

扩展参数

通常,需要一些扩展参数,来做一些外围的事情。

  • 联调。为了更好地联调,通常会设计一个必传的 requestId 。

  • 运维。如果导出因为偶然因素而失败怎么办? 可以设计一个 exportId ,针对该导出ID进行导出和修复。

  • 监控与统计。 调用源 source 实际上是用来统计的,并非必要参数。监控与统计,尽量依赖内部的状态,而不是API参数。

清单四:


@Data
public class RefundExportParamV4 {
  /** 调用方,必传 */
  private String source;

  /** 请求ID,必传 */
  private String requestId;

  /** 导出ID, 运维使用 */
  private Long exportId;

  /** 退款搜索参数 */
  private RefundSearchParam search;
}

如清单四所示。 要特别注意的是,这些外围、扩展参数是否会淹没了必要参数。有一种办法是,将这些非必要参数,都放到一个 Map 里,然后提供一些工具方法来设置。 孰优孰劣,读者来评判。

总结下: 通过三个类的组合 (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;
  /** 请求ID,必传 */
  private String requestId;
  /** 导出ID, 运维使用 */
  private Long exportId;
}

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,必传 */
  private String requestId;
  /** 导出ID, 运维使用 */
  private Long exportId;
}

这样是不是可以了? 想一想,如果搜索里面有一些必传参数要进行强校验,比如归属(店铺ID),起始时间等,从 Condition 里解析出这些条件可是不容易哦。 最好抽离出来。

清单七:


@Data
public class ExportParam {
  /** 调用方,必传 */
  private String source;
  /** 导出业务类型,必传 */
  private String bizType;
  /** 搜索参数,必传 */
  private SearchParam search;
  /** 请求ID,必传 */
  private String requestId;
  /** 导出ID, 运维使用 */
  private Long exportId;
}

@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' 。这样,无论是合并统计还是区分对待,都更加清晰。

如何区分订单维度和商品维度的导出呢?这个相对容易解决。维度只是一个导出选项。 可以在 ExportParam 增加一个 options:Map 参数, 提供定制化的可以组合的导出选项。导出选项有维度、文件格式等。这些选项参数如果直接放在 ExportParam ,会让这个类变得臃肿。

如何区分标准报表导出和自定义报表导出呢? 标准和自定义是策略。标准和自定义可能是多个导出选项的组合。不适合放在 options 里;同时,标准和自定义可能适用于所有的业务类型和细分类,是一个切面概念。因此设置一个 strategy 参数。 这个 strategy 可以决定一些选项的组合设置。

现在,梳理一下导出分类的几个层面:业务类型 ( order, refund ) - 细分类 (default, fenxiao) - 策略 ( strategy ) - 选项 (options) 。 这些是否足够对所有导出进行分类了。 这也说明了,当一个服务要接入多个业务类型时,需要进行仔细的分类。

小结

API 是软件应用向外部提供自身服务的一种形态和公开接口。就像一个人的着装打扮、举止言行、形象状态,是其内在的某种体现。本文通过导出API的设计,讨论了设计API需要考虑的一些因素和选择。读者不妨针对自己工作中所遇到和学到的API,也做类似的思维体操,相信是很有裨益的。

猜你喜欢

转载自www.cnblogs.com/lovesqcc/p/10224011.html
今日推荐