原文首發於InfoQ:如何優雅的實現一個 Client
創建Client的主要目的是方便與Server進行交互,進而操作Server的數據或資源。Client可以採用不同的協議和Server進行交互,這完全取決於Server支持哪些協議,比如TCP、UDP、HTTP(S)、WebSocket、gRPC等等。使用不同協議的Client實現複雜度和技巧不同,要解決的核心問題也有所不同。本文無法也沒有能力針對使用不同協議的Client提供一些有益的指導,爲了下文更加清晰的描述,本文限定Client使用HTTP(S)協議與Server交互,Client調用Server開放的標準RESTful API操作Server的資源(創建、刪除、更新、查詢)。本文所有的樣例均使用Java語言編寫,不同語言在語法層面有所不同,解決同一問題的思路也不不一樣,但是解決問題的思想是一致的,讀者可以使用自己擅長的語言實現。
0 原則
設計一個優雅的Client應當站在使用者的角度,幫助使用者解決痛點,把困難留給自己,便利留給使用者,遵循以下原則有助於設計一個優雅的Client:
文檔完整原則:正確、清晰、完整的使用文檔和接口文檔。
靈活配置原則:Client的配置應當以接口的形式開放,以提供使用者控制Client行爲的能力。
接口清晰原則:接口定義清晰,接口名能夠自解釋。
最小開放原則:Client最小化開放的接口,接口的內部實現應當對使用者隱藏。
測試完備原則:完備的單元測試、集成測試。
1 Server的API定義
爲了方便編寫樣例代碼以闡述Client的設計思路,本文以構建Gotify的Client爲例進行說明。Gotify是一個開源的用於發送和接受消息的Server,有興趣的讀者可以去官網查閱相關文檔並親自體驗。
2 設計Gotify Client
2.1 開放Client配置
按照Client的設計原則,我們要做的第一件事情就是將Client必要的配置以接口的形式開放給Client的使用者,因此我們思考的第一件事情是Client有哪些必要的配置需要開放給使用者,Client需要開放的配置與具體的Server相關,對於Gotify來說,最基本的配置包括:Gotify的監聽地址,Gotify的監聽端口,Gotify是否開啓ssl,如果開啓ssl還需要配置相應的證書,總結下來Client只需要開放四個必要配置。爲了簡單又不失一般性,在文中僅開放三個必要配置:Gotify的監聽地址,Gotify的監聽端口,訪問Gotify使用的協議(HTTP/HTTPS),下面使用Java的Interface定義Client需要開放的配置:
public interface GotifyClientConfig {
default String getScheme() {
return "http";
}
String getHost();
int getPort();
}
Client開放的配置已經定義完成,使用者可以根據自己Gotify Server的情況實現該接口,但是要求使用者提供一個完整的實現,這對於使用者來說非常的不方便。我們需要思考解決使用者如何簡單方便的創建Client配置 ,下面我們從簡單易用性出發,考慮提供幾個方便使用者創建配置的工具。
2.1.1 通過工廠方法創建配置
Client一共三個配置參數,其中兩個必選參數,一個可選參數,參數的數量不是很多,因此我們可以考慮提供兩個工廠方法(一個包含三個參數,一個包含兩個參數),參數的位置按照scheme:host:port的位置進行排列(每個細節都以使用者爲中心設計),因爲這和三個參數在標準Uri中的順序一致,方便使用者記憶。
方法1:擁有三個參數的工廠方法
public static GotifyClientConfig build(String scheme, String host, int port) {
return new GotifyClientConfig() {
@Override
public String getScheme() {
return scheme;
}
@Override
public String getHost() {
return host;
}
@Override
public int getPort() {
return port;
}
};
}
方法2:擁有兩個參數的工廠方法
public static GotifyClientConfig build(String host, int port) {
return build("http", host, port);
}
我們已經定義創建Client配置的工廠方法,下一個需要我們思考的問題是:這些工廠方法應該被組織到哪個類中?在本例子中我們將工廠方法組織到GotifyClientConfig
接口中,以下兩點支撐我們做出這個決定:1、目前爲止我們開放給使用者的只有GotifyClientConfig
接口,如果將方法組織到新的類中,比如我們創建一個工廠類GotifyClientConfigFactory
,那麼這會增加使用的負擔,每增加一個開放的類,使用者的學習負擔都在增加;2、Java 8 之後的版本從語言層面支持我們這麼做,interface提供一些工廠方法在很多場景下都很適用。因此我們將工廠方法組織到GotifyClientConfig
接口中,修改後的GotifyClientConfig
接口如下:
public interface GotifyClientConfig {
static GotifyClientConfig build(String scheme, String host, int port) {
return new GotifyClientConfig() {
@Override
public String getScheme() {
return scheme;
}
@Override
public String getHost() {
return host;
}
@Override
public int getPort() {
return port;
}
};
}
static GotifyClientConfig build(String host, int port) {
return build("http", host, port);
}
// 省略其他方法
}
2.2.2 使用Builder模式
工廠方法可以很好的解決在Client中遇到的關於開放配置的問題,但是使用工廠方法有一點限制。工廠方法僅適用於配置參數不多,比如1-4個參數。複雜Client的配置的參數往往非常的多,超過10個參數都是很正常的,在這種多配置參數情況下使用工廠方法就非常的不方便,使用起來甚至比提供一個完整的實現難度還要大,一個擁有超多參數的方法是難以被正確使用的。對於這種多參數場景,設計模式中的Builder模式(建造者模式)是解決這類問題的不二法寶。下面我以常規的寫提供一個Builder來簡化配置的創建工作。
第一步:定義一個不對使用者開放的
GotifyClientConfig
實現第二步:定義Builder。
Builder的定義非常簡單,而且都是樣板代碼,需要我們重點考慮的是:Builder是一個獨立的開放類,還是一個開放類的內部類。如果作爲內部類,應該作爲哪個類的內部類?關於這一點我們建議是根據參數的個數選擇,比如像本文中配置的參數很少,那麼Build作爲
GotifyClientConfig
接口的內部類是可以。對於擁有較多參數的配置,建議開放一個獨立類,對於獨立的類這裏又分兩種情況,如果接口的實現開放那麼Build可以作爲接口實現類的內部類,如果接口實現類不開放,那麼則需要創建一個獨立的類。考慮使用獨立類的主要目的是降低閱讀GotifyClientConfig
接口的難度,減少工具方法的干擾。
GotifyClientConfig
的設計、開發工作已經完成,使用者可以使用下面的兩種方式根據Gotify Server的實際情況創建GotifyClientConfig
實例。
方式一:使用工廠方法
方式二:使用Builder
接下來讓我們一起設計Gotify Client。
2.2 設計Gotify Client
上文提到Client的主要功能是與Server進行交互以操作Server的數據/資源,那麼設計Client之前,需要掌握Server開放了多少個操作數據的接口,接口如何使用,接口有沒有分類等信息。Gotify Server開放的是標準RESTful API,接口的使用非常的方便,可以通過命令行工具、HTTP 客戶端等。使用接口不是難點,因此我們要分析的重點是Gotify提供了多少API,API是否有分類,如何在Client中優雅組織實現並開放API。
Gotify一共開放7大類31個API,7大類API分別操作application、message、client、user、health、plugin、version資源。如何組織31個API是需要設計的第一點,按照是否將所有的API通過一個接口開放,API的組織形式可以分爲兩類:
集中式,將31個API全部通過一個接口開放,使用者創建一個Client/接口就可以使用全部的API。
分類式,按照Server對API的分類,將不同的API組織到不同的Client/接口中,比如將API通過AppClient,MessageClient等多個接口開放。
兩種組織API的方式孰優孰劣?其實,不管哪種方案都無法全面優於另一種方案,每個方案都有自己適用的場景,API的數量是選擇何種組織方式的主要考慮因素。API的數量比較少選擇集中式,這樣使用者的學習使用成本都比較低,Client本身的實現複雜度也會降低。如果API的數量較多,而且Server已經按照資源將API分類,使用分類式的方式組織API就更加順理成章,做出這種選擇主要是因爲以下兩點:
很少有使用者需要使用全部的API。
分類可以降低使用者的學習成本,使用者只要學習自己需要的API。在衆多的API中選擇使用合適的API本身就是一件比較困難的事情,尤其在API設計不是很合理的情況下。
選擇使用分類式的方式組織API,那麼如何設計這些分類的Client呢?在設計之前我們需要回答以下問題:
Client有何異同?
如何創建Client?
Client是否應有狀態?
Client是否線程安全?
Client應該是單例還是多例?
弄清楚上面的問題,Client的設計、實現方案也就確定了。
問題1::Client有何異同?
異:不同Client開放的接口不同,不同的Client操作不同的數據/資源,這個不同點是很自然的。
同:不同的Client都要和Server進行交互,都需要知道Server的信息,也就是說不同的Client都依賴
GotifyClientConfig
。不同的Client從邏輯上講都是Server Client的一部分,因此我們需要定義這些Client的相同部分,弄清楚這一點對於理解下穩定義和實現Client的方式非常的重要。問題2::如何創建Client?
創建Client的方式多種多樣,但是Client的創建方式應當統一,統一就可以降低使用者學習使用不同Client的難度。
問題3: Client是否應該有狀態?
這是實現的一種權衡,無狀態的Client實現更加容易。有狀態Client的實現難度更、出錯的概率更高,需要解決的問題也會更多,但是可能提供更好的使用體驗,比如緩存數據可以提升API的響應速度。
問題4:Client是否線程安全?
爲了降低使用者的使用難度,我們建議儘量將Client設計並實現爲線程安全的Client。
問題5: Client應該是單例還是多例?
線程安全的Client實現爲單例和多例區別不大,但是爲了避免潛在資源的浪費,建議按照單例實現。非線程安全Client建議實現爲多例,以降低使用出錯的概率。
問題分析清楚以後Client的設計方案也就浮出水面,我們設計的Client將具備這樣的特點:無狀態且線程安全,使用工廠方法創建單例Client,所有的Client都實現了同一個接口用於表明這些Client歸屬一類。Client的邏輯示意圖如下:
2.2.1 定義Client的共同行爲
所有Client的共同行因爲Client不同而有所不同,在本文中所有的Client的共同行爲是都支持關閉。在Java中可以使用接口和抽象類定義允許有多個實現的類型,使用接口是比抽象類更佳的優秀實踐。如果只是想定義一個類型,那麼標記接口(不包含任何方法的接口)是一種不錯的選擇。
public interface CloseableClient {
void close();
}
2.2.2 定義操作不同資源的Client
AppClient:
public interface AppClient extends CloseableClient {
Iterable<Application> listApplication();
boolean deleteApplication(String id);
}
MessageClient:
public interface MessageClient extends CloseableClient {
Iterable<Message> listMessageOfApplication(String appId);
boolean deleteOneMessage(String id);
}
操作資源的Client已經定義完整,但是先不要着急去實現這些Client,下一步我們要解決的問題是如何方便的創建這些Client。
2.2.3 使用工廠方法創建Client
按照上文對方案的介紹,GotifyClient
將負責創建AppClient
、MessageClient
等操作資源的Client,下面是GotifyClient的定義:
public interface GotifyClient {
AppClient getAppClient();
MessageClient getMessageClient();
}
所有操作資源的Client,比如AppClient
都將由GotifyClient
負責創建。對外的接口已經定義清楚,下面來分別實現AppClient
、MessageClient
和GotifyClien
t,注意這些Client的實現都不對使用者開放。
2.2.4 AppClient和MessageClient的實現
AppClient
和MessageClient
的實現需要滿足我們對Client的設計要求:Client無狀態,Client線程安全。
AppClient 的實現
class AppClientImpl implements AppClient {
private GotifyClientConfig clientConfig;
public AppClientImpl(GotifyClientConfig clientConfig) {
this.clientConfig = clientConfig;
}
// 省略實現的接口
}
MessageClient 的實現
class MessageClientImpl implements MessageClient {
private GotifyClientConfig clientConfig;
public MessageClientImpl(GotifyClientConfig clientConfig) {
this.clientConfig = clientConfig;
}
// 省略實現的接口
}
2.2.5 實現GotifyClient
GotifyClient
的實現需要滿足我們對Client的設計要求:Client是單例,Client的創建方式是統一的。
class GotifyClientImpl implements GotifyClient {
private GotifyClientConfig clientConfig;
private AtomicReference<AppClient> appClientRef = new AtomicReference<>();
private AtomicReference<MessageClient> messageClientRef = new AtomicReference<>();
public GotifyClientImpl(GotifyClientConfig clientConfig) {
this.clientConfig = clientConfig;
}
@Override
public AppClient getAppClient() {
return newClient(appClientRef, AppClientImpl::new);
}
@Override
public MessageClient getMessageClient() {
return newClient(messageClientRef, MessageClientImpl::new);
}
// 實現Client的單例要求,而且統一了Client的創建方式
private synchronized <T extends CloseableClient> T newClient(AtomicReference<T> reference,
Function<GotifyClientConfig, T> factory) {
T client = reference.get();
if (Objects.isNull(client)) {
client = factory.apply(clientConfig);
reference.lazySet(client);
}
return client;
}
}
2.2.6 提供工廠方法創建GotifyClient
我們參考GotifyClientConfig
的實現方式,在GotifyClient
接口中添加一個靜態方法,修改後的GotifyClient
接口如下:
public interface GotifyClient {
static GotifyClient build(GotifyClientConfig config) {
return new GotifyClientImpl(config);
}
AppClient getAppClient();
MessageClient getMessageClient();
}
3 GotifyClient 的使用樣例
根據上面設計,我們使用GotifyClient
過程可以分爲四步:
根據需要確定需要哪些資源,需要使用哪些Client?
獲取Gotify Server的基本信息,創建
GotifyCLientConfig
創建
GotifyClient
使用GotifyClient創建需要的操作資源的Client
下面以獲取運行在本地監聽6875端口的Gotify 所有Application爲例,詳細講解如何使用我們上面設計的Client。
// 步驟一:創建配置
GotifyClientConfig config = GotifyClientConfig.Builder.builder()
.scheme("http")
.host("localhost")
.port(6875)
.build();
// 使用GotifyClientConfig創建GotifyClient
GotifyClient gotifyClient = GotifyClient.build(config);
// 使用GotifyClient創建AppClient
AppClient appClient = gotifyClient.getAppClient();
appClient.listApplication().forEach(System.out::println);
4 總結
實現一個Client一定要站在使用者的角度,以使用者爲中心,對使用者屏蔽實現的細節和複雜性,將困難留給自己。總結起來實現一個優雅的Client的需要着重提高Client的封裝性和易用性。一個優雅的Client不僅使用者使用起來優雅,實現者也要能夠優雅的實現,優雅的修改,這就要求我們的Client一定要很好的封裝,僅開放必須開放的接口,內部實現儘量不要暴露給使用者。Client的目的就是爲了幫助使用者更好的和Server進行交互,因此易用性更加重要,易用性高的Client才能得到使用者的認可,簡單易用的Client也可以降低使用出錯的概率。
5 附錄
1、樣例代碼倉庫:https://github.com/ctlove0523/gotify-java-client
2、Gotify項目地址:https://github.com/gotify