java與es8實戰之一:以builder pattern開篇

歡迎訪問我的GitHub

這裏分類和彙總了欣宸的全部原創(含配套源碼):https://github.com/zq2599/blog_demos

關於《java與es8實戰》系列

  • 《java與es8實戰》系列是欣宸與2022年夏季推出的原創系列,如標題所述,該系列從一個java程序員視角去學習和實踐elasticsearch的8.2版本,目標是與大家一起掌握與elasticsearch開發相關的技能,以應對實際應用中的需求和挑戰

本篇概覽

  • 縱觀欣宸過往各種系列文章,開篇無外乎兩種套路
  1. 第一種是對該系列的主題做重點介紹,把重點、背景說清楚
  2. 第二種更加實在,就是準備工作,例如安裝相關的軟件,介紹對應版本,甚至寫個初級的hello world
  • 那麼《java與es8實戰》系列的開篇應該是哪種風格?是介紹elasticsearch?還是動手部署一套es集羣?亦或是用java寫一套簡單的增刪改查代碼,讓大家可以快速入門?
  • 這個問題難住我了,思考良久,想到剛開始寫es代碼時的困惑,那時去看es的java庫源碼中的單元測試部分,研究如何調用java庫的api,看到那裏是這麼寫代碼的,先是創建索引,創建請求對象會用到builder
image-20220611190657698
  • 再隨意逛到了批量操作的代碼,如下圖,還是builder
image-20220611191032968
  • 最常用的聚合查詢,如下圖,也離不開builder
image-20220611191218798
  • 於是我就納悶了:以後寫es相關的代碼,這builder操作難道會一直伴隨我?
  • 去翻閱es的官方文檔,發現說的很清楚:Java客戶端中的數據對象都是不可變的,這些數據對象在創建時用的是2008版本《Effective Java》中的builder模式
image-20220611191428225
  • 回憶了這麼多,我終於想清楚《java與es8實戰》的開篇內容了:咱們不急着部署ES,也不急着寫增刪改查的入門級代碼,今天,欣宸邀您一同去溫習經典,搞清楚以下問題:
  1. 直接用構造方法創建對象有什麼問題?
  2. 用靜態方法創建對象有什麼問題?
  3. builder模式是什麼?
  4. builder模式解決了什麼問題?
  5. builder模式自己有啥問題?
  6. es API和builder有啥關係?
  • 等咱們搞清楚這些問題,寫代碼操作es時遇到builder就不再疑惑,而是感受到builder帶來的好處,進而養成習慣,在今後設計不可變類時自然而然的用上builder模式,那時候您不一定還在用es,然而builder模式可以長久陪伴您,因爲,經典就是經典,如下圖
40
  • 現在,咱們java程序員的es8開發之旅,就從經典的builder pattern出發

不可變對象(Immutable Objects)

  • es的API中的對象都是不可變的(immutable),關於不可變,簡單的說就是:實例一旦創建後,不能改變其成員變量的值
  • 本篇文章討論的創建對象,都是指的不可變對象

三種創建對象的常用方法

  • 這三種分別是
  1. 構造方法
  2. 靜態工廠方法
  3. builder模式

直接用構造方法創建對象有什麼問題

  • 創建一個對象,最常用的方法不就是構造方法麼?new Object()不香嗎?

  • 成員變量很多的時候,構造方法就沒那麼香了,舉例如下,NutritionFacts是食品包裝外面顯示的營養成分標籤,這裏面有的營養成分是必須的:每一份的含量、每一罐的含量,其他的可選

public class NutritionFacts {
    private final int servingSize;  // (mL)            required
    private final int servings;     // (per container) required
    private final int calories;     //                 optional
    private final int fat;          // (g)             optional
    private final int sodium;       // (mg)            optional
    private final int carbohydrate; // (g)             optional

    public NutritionFacts(int servingSize, int servings) {
        this(servingSize, servings, 0);
    }

    public NutritionFacts(int servingSize, int servings,
            int calories) {
        this(servingSize, servings, calories, 0);
    }

    public NutritionFacts(int servingSize, int servings,
            int calories, int fat) {
        this(servingSize, servings, calories, fat, 0);
    }

    public NutritionFacts(int servingSize, int servings,
            int calories, int fat, int sodium) {
        this(servingSize, servings, calories, fat, sodium, 0);
    }

    public NutritionFacts(int servingSize, int servings,
           int calories, int fat, int sodium, int carbohydrate) {
        this.servingSize  = servingSize;
        this.servings     = servings;
        this.calories     = calories;
        this.fat          = fat;
        this.sodium       = sodium;
        this.carbohydrate = carbohydrate;
    }
}
  • 從上面的代碼可見,爲了儘量滿足用戶需要,NutritionFacts提供了多個構造方法給用戶使用,其實相信您也明白這裏面的問題:這簡直是成員變量的各種排列組合呀,以後要是加字段就麻煩了
  • 再以一個使用者的視角來看看,實例化代碼如下,這就有點暈了,這一眼看過去,誰知道240給了哪個字段?只能去核對構造方法的入參聲明
NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0, 35, 27);
  • 緩解上述問題的一種方法是使用JavaBeans模式,用無參構造方法,然後按照調用setXXX設置每個所需字段,示例如下所示
NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);
  • 上述方法似乎不錯,哪些字段被設置一目瞭然,所以,成員變量多的時候,用上述方法是正確選擇嗎?
  • 然而,《Effective Java》原著對上述做法的評價是有着嚴重的弊端(the JavaBeans pattern has serious disadvantages of its own),所以,儘早放棄吧...咱們來看看具體有啥問題
  1. 首先,直觀的看,這種做法違背了不可變對象的定義,創建出對象後,又用setXXX方法改變了成員變量
  2. 《Effective Java》的原話是在構造過程中JavaBean可能處於不一致的中的狀態,我的理解如下圖所示,不用顏色代表不同線程,可以看到,紅色線程獲取calories的值的時候,藍色線程還沒有開始設置calories的值,所以紅色線程拿到的等於初始值0,這顯然是不對的,正常邏輯應該是:只要cocaCola對象非空,其calories字段對外顯示的值就是100
流程图 (9)
  1. 經驗豐富的您應該想到了這是典型的線程同步問題,應該用synchronize或ReentrantLock給藍色代碼段加鎖,讓紅色代碼先block住,直到藍色代碼執行完畢,這樣就能拿到正確的值了---這種方法顯然可以解決問題,然而《Effective Java》預判了您的預判:這種方式十分笨拙,在實踐中很少使用,想想也是,創建和使用對象是最常見的編碼了,這個思路要加多少synchronize或ReentrantLock
  • 所以構造方法不能滿足我們的實際需要,再來看看靜態工廠方法,它的優勢在哪裏

靜態工廠方法的優勢

  • 相比靜態工廠方法,構造方法存在以下五個典型問題
  1. 隨着入參的不同,構造方法可以有多個,如下所示,然而都是同名的,這會給用戶造成困惑,此刻用靜態工廠方法,可以自由設置方法名(例如createWithName或者createWithAge),讓用戶更方便的選擇合適的方法
    public Student(String name) {
        this.name = name;
    }

    public Student(int age) {
        this.age = age;
    }
  1. 使用構造方法意味着創建對象,而有時候我們只想使用,並不在乎對象本身是否是新建的,下面是Boolean.valueOf方法的源碼,此處並未新建Boolean對象:
    public static Boolean valueOf(String s) {
        return parseBoolean(s) ? TRUE : FALSE;
    }
  1. 以動物類Animal.class爲例,Animal類的構造方法創建的對象Animal的實例,而靜態工廠方法的返回值聲明雖然是Animal,但實際返回的實例可以是Animal的子類,例如Dog
  2. 靜態工廠方法內部可以有靈活的邏輯來決定返回那種子類的實例,來看的靜態工廠方法源碼,根據底層枚舉類型的大小來決定是返回RegularEnumSet實例還是JumboEnumSet實例
    public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType) {
        Enum<?>[] universe = getUniverse(elementType);
        if (universe == null)
            throw new ClassCastException(elementType + " not an enum");

        if (universe.length <= 64)
            return new RegularEnumSet<>(elementType, universe);
        else
            return new JumboEnumSet<>(elementType, universe);
    }
  1. 靜態工廠方法還有一個優勢:方法返回對象所屬的類,在編寫此靜態方法時可以不存在,這句話有點晦澀,可以回想一下JDBC的獲取connection的API,在編寫此API的時候,並不需要知道MySQL的driver實現
  • 以上的比較暴露出構造方法的缺陷,此時靜態工廠方法更加合適,然而,靜態工廠方法就這麼完美嗎?

靜態工廠方法的不足

  • 只有最合適的,沒有最好的,靜態工廠方法也有自己的不足
  1. 當您開發一個類時,如果決定對外提供靜態工廠方法,那麼將構造方法設爲私有,就可以讓用戶只能選擇靜態工廠方法了,代碼如下所示,然而,這樣的Student類就無法被繼承
public class Student {
    private String name;

    private int age;

    public void setName(String name) {
        this.name = name;
    }

    private Student() {

    }

    public static Student newInstance(String name) {
        Student student = new Student();
        student.setName(name);

        return student;
    }
}
  1. 一個類的代碼中,可能已有一些靜態方法,再加入靜態工廠方法,一堆靜態方法混雜在一起,用戶從中找出靜態工廠方法怕是不容易

builder pattern

  • 看過了構造方法和靜態工廠方法,認識到它們的不足,終於該第三種方法登場了
  • builder pattern,《Effective Java》中文版譯作建造者模式,用builder對象來創建真正的對象實例,前面提到的構造方法和靜態工廠的不足,在builder pattern這裏都得到了改善
  • 來看代碼吧,以剛纔的NutritionFacts爲例,使用builder pattern後的代碼如下,新增一個靜態成員類Builder,可以設置Builder的每個成員變量,最後調用其build方法的時候,才真正創建NutritionFacts對象
public class NutritionFacts {
    private final int servingSize;
    private final int servings;
    private final int calories;
    private final int fat;
    private final int sodium;
    private final int carbohydrate;

    public static class Builder {
        // Required parameters
        private final int servingSize;
        private final int servings;

        // Optional parameters - initialized to default values
        private int calories      = 0;
        private int fat           = 0;
        private int carbohydrate  = 0;
        private int sodium        = 0;

        public Builder(int servingSize, int servings) {
            this.servingSize = servingSize;
            this.servings    = servings;
        }

        public Builder calories(int val)
            { calories = val;      return this; }
        public Builder fat(int val)
            { fat = val;           return this; }
        public Builder carbohydrate(int val)
            { carbohydrate = val;  return this; }
        public Builder sodium(int val)
            { sodium = val;        return this; }

        public NutritionFacts build() {
            return new NutritionFacts(this);
        }
    }

    private NutritionFacts(Builder builder) {
        servingSize  = builder.servingSize;
        servings     = builder.servings;
        calories     = builder.calories;
        fat          = builder.fat;
        sodium       = builder.sodium;
        carbohydrate = builder.carbohydrate;
    }
}
  • 以一個使用者的視角來看如何創建NutritionFacts對象,如下所示,流暢的寫法,那些字段被設置以及具體的值都一目瞭然,最終build方法纔會創建NutritionFacts對象,而且這是個不可變對象
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
                              .calories(100)
                              .sodium(35)
                              .carbohydrate(27)
                              .build();

builder pattern自身的問題和適用場景

  • 即便能解決構造方法和靜態工廠自身的一些問題,builder pattern也不是萬能的,缺點很明顯:創建對象之前,先要創建builder對象,這在一些性能要求高、資源限制苛刻的場景中就不適合了
  • 另外builder pattern適合的場景是成員變量多的時候,而這個所謂的究竟如何理解呢?這可能是個小馬過河的問題吧:見慣了幾十個成員變量的類,再去看十幾個成員變量的類,可能會有種很清爽的感覺,呃,扯遠了,其實《Effective Java》的說法是四個或者更多個參數,就適合用builder apttern了

elasticsearch API中的builder

  • 終於到達重點了:接下來的es之旅,會遇到什麼樣的builder?咱們該怎麼用它?
  • 先總結builder的使用套路,其實在es中的builder也是按照套路去用的,如下圖,其實很簡單,三步走而已,暫時把下圖稱爲套路圖,後面還會提到
流程图 (10)
  • 看看es API的用法,以es自己的單元測試代碼爲例,如下圖所示,創建一個索引時,會指向紅色箭頭所指的create方法

image-20220612093954780

  • 來看看create的源碼,入參是個Function,裏面執行了function的apply,這是個典型的lambda表達式作爲入參
	public final CreateIndexResponse create(Function<CreateIndexRequest.Builder, ObjectBuilder<CreateIndexRequest>> fn)
			throws IOException, ElasticsearchException {
		return create(fn.apply(new CreateIndexRequest.Builder()).build());
	}
  • Function的兩個泛型,第一個表示入參,第二個表示返回,對於create方法的用戶來說,這就有意思了:
  1. 咱們在寫這個lambda表達式時,入參是builder對象,這可以從上面的代碼中看到(即apply方法的入參),也就是說套路圖中的第一步:創建builder對象,已經被create方法內部做好了
  2. 再看看上面的截圖中,lambda表達式做了什麼? b.index("my-index"),這裏可以按照實際業務需要調用builder的多個方法來來設置參數,所以套路圖中的第二步,需要咱們在lambda表達式中完成,這很合理:需要設置哪些參數只有用戶最清楚
  3. 最後,也是最巧妙的地方,就是上面的create方法源碼中的.build(),因爲fn.apply方法的實現是調用者寫的,例如剛纔寫的是 b.index("my-index"),這個index方法的返回值就是build實例,所以fn.apply(xxx).build()就是套路圖中的第三步:builder的build方法被執行了,真正的對象,即CreateIndexRequest對象此刻被創建,這也被es內部給做好了
  • 小結如下圖
    在這裏插入圖片描述
  • 看到這裏,不知您是否會擊掌叫好,builder與lambda的巧妙結合,整個套路中,第二步留給使用者按需定製,而固定的第一和第三步都被es自己實現,對使用者來說顯得非常精簡,而整個過程並無特殊之處,都是對經典的嫺熟應用
  • 經歷了本文,今後在寫es操作代碼時,面對各種builder和lambda,相信您不再迷茫,取而代之的是模式的欣賞和品味,以及本就該如此的感悟
  • 網絡上寫es開發的系列文章並不少,像欣宸這樣拿builder做開篇的,應該獨一無二了...吧
  • 好了,《java與es8實踐》的畫卷已順利展開一角,接下來,請允許欣宸原創繼續陪伴您,像今天這樣踏踏實實,一步一個腳印,從入門到精通

歡迎關注博客園:程序員欣宸

學習路上,你不孤單,欣宸原創一路相伴...

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