从新设计导出API

优雅的API是清晰简洁的,就像少女的肌肤同样柔滑。html

背景

API 是软件应用向外部提供自身服务的一种形态和公开接口。就像一我的的着装打扮、举止言行、形象状态,是其内在的某种体现。不多有人能看到对方灵魂的内涵,但经过公共接口,能够略窥一二。前端

之前缺少API设计的意识,没有通过仔细的思考,作出来的API够用,但比较粗糙。若是要从新设计API,会是怎样呢? 本文以导出API为例,试以阐释。 限于我的知识和经验,如有不对之处,欢迎指出 :)java


好的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;

}

注意:

  1. 扩展参数过多,会致使必要参数不够凸显。有一种办法是,将这些扩展参数,都放到一个 Map 里,而后提供一些工具方法来设置。好比 exportId, filePath 都放到 options 里。 少一个参数,少一分干扰。
  2. options 和 extra 虽然都是 map ,但做用是不同的。 options 会影响导出结果, 而 extra 不会。 所以,必须将二者的语义分开。
  3. 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,也作相似的思惟体操,相信是颇有裨益的。

相关文章
相关标签/搜索