如何優雅的實現一個Client 0 原則 1 Server的API定義 2 設計Gotify Client 3 GotifyClient 的使用樣例 4 總結 5 附錄

原文首發於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將負責創建AppClientMessageClient等操作資源的Client,下面是GotifyClient的定義:

public interface GotifyClient {

    AppClient getAppClient();

    MessageClient getMessageClient();
}

所有操作資源的Client,比如AppClient都將由GotifyClient負責創建。對外的接口已經定義清楚,下面來分別實現AppClientMessageClientGotifyClient,注意這些Client的實現都不對使用者開放

2.2.4 AppClient和MessageClient的實現

AppClientMessageClient的實現需要滿足我們對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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章