從新設計導出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,也作相似的思惟體操,相信是頗有裨益的。

相關文章
相關標籤/搜索