設計模式 | 組合模式及典型應用

小旋鋒發表於2018-10-05

本文的主要內容:

  • 介紹組合模式
  • 示例
  • 組合模式總結
  • 原始碼分析組合模式的典型應用
    • java.awt中的組合模式
    • Java集合中的組合模式
    • Mybatis SqlNode中的組合模式

更多內容可訪問我的個人部落格:laijianfeng.org

關注【小旋鋒】微信公眾號

推薦閱讀

設計模式 | 簡單工廠模式及典型應用
設計模式 | 工廠方法模式及典型應用
設計模式 | 抽象工廠模式及典型應用
設計模式 | 建造者模式及典型應用
設計模式 | 原型模式及典型應用
設計模式 | 外觀模式及典型應用
設計模式 | 裝飾者模式及典型應用
設計模式 | 介面卡模式及典型應用
設計模式 | 享元模式及典型應用

組合模式

樹形結構不論在生活中或者是開發中都是一種非常常見的結構,一個容器物件(如資料夾)下可以存放多種不同的葉子物件或者容器物件,容器物件與葉子物件之間屬性差別可能非常大。

由於容器物件和葉子物件在功能上的區別,在使用這些物件的程式碼中必須有區別地對待容器物件和葉子物件,而實際上大多數情況下我們希望一致地處理它們,因為對於這些物件的區別對待將會使得程式非常複雜。

一個簡化的Linux目錄樹

組合模式為解決此類問題而誕生,它可以讓葉子物件和容器物件的使用具有一致性

組合模式(Composite Pattern):組合多個物件形成樹形結構以表示具有 "整體—部分" 關係的層次結構。組合模式對單個物件(即葉子物件)和組合物件(即容器物件)的使用具有一致性,組合模式又可以稱為 "整體—部分"(Part-Whole) 模式,它是一種物件結構型模式。

由於在軟體開發中存在大量的樹形結構,因此組合模式是一種使用頻率較高的結構型設計模式,Java SE中的AWT和Swing包的設計就基於組合模式。

除此以外,在XML解析、組織結構樹處理、檔案系統設計等領域,組合模式都得到了廣泛應用。

角色

Component(抽象構件):它可以是介面或抽象類,為葉子構件和容器構件物件宣告介面,在該角色中可以包含所有子類共有行為的宣告和實現。在抽象構件中定義了訪問及管理它的子構件的方法,如增加子構件、刪除子構件、獲取子構件等。

Leaf(葉子構件):它在組合結構中表示葉子節點物件,葉子節點沒有子節點,它實現了在抽象構件中定義的行為。對於那些訪問及管理子構件的方法,可以通過異常等方式進行處理。

Composite(容器構件):它在組合結構中表示容器節點物件,容器節點包含子節點,其子節點可以是葉子節點,也可以是容器節點,它提供一個集合用於儲存子節點,實現了在抽象構件中定義的行為,包括那些訪問及管理子構件的方法,在其業務方法中可以遞迴呼叫其子節點的業務方法。

組合模式的關鍵是定義了一個抽象構件類,它既可以代表葉子,又可以代表容器,而客戶端針對該抽象構件類進行程式設計,無須知道它到底表示的是葉子還是容器,可以對其進行統一處理。同時容器物件與抽象構件類之間還建立一個聚合關聯關係,在容器物件中既可以包含葉子,也可以包含容器,以此實現遞迴組合,形成一個樹形結構。

示例

我們來實現一個簡單的目錄樹,有資料夾和檔案兩種型別,首先需要一個抽象構件類,宣告瞭資料夾類和檔案類需要的方法

public abstract class Component {

    public String getName() {
        throw new UnsupportedOperationException("不支援獲取名稱操作");
    }

    public void add(Component component) {
        throw new UnsupportedOperationException("不支援新增操作");
    }

    public void remove(Component component) {
        throw new UnsupportedOperationException("不支援刪除操作");
    }

    public void print() {
        throw new UnsupportedOperationException("不支援列印操作");
    }

    public String getContent() {
        throw new UnsupportedOperationException("不支援獲取內容操作");
    }
}
複製程式碼

實現一個資料夾類 Folder,繼承 Component,定義一個 List<Component> 型別的componentList屬性,用來儲存該資料夾下的檔案和子資料夾,並實現 getName、add、remove、print等方法

public class Folder extends Component {
    private String name;
    private List<Component> componentList = new ArrayList<Component>();

    public Folder(String name) {
        this.name = name;
    }

    @Override
    public String getName() {
        return this.name;
    }

    @Override
    public void add(Component component) {
        this.componentList.add(component);
    }

    @Override
    public void remove(Component component) {
        this.componentList.remove(component);
    }

    @Override
    public void print() {
        System.out.println(this.getName());
        for (Component component : this.componentList) {
            component.print();
        }
    }
}
複製程式碼

檔案類 File,繼承Component父類,實現 getName、print、getContent等方法

public class File extends Component {
    private String name;
    private String content;

    public File(String name, String content) {
        this.name = name;
        this.content = content;
    }

    @Override
    public String getName() {
        return this.name;
    }

    @Override
    public void print() {
        System.out.println(this.getName());
    }

    @Override
    public String getContent() {
        return this.content;
    }
}
複製程式碼

我們來測試一下

public class Test {
    public static void main(String[] args) {
        Folder DSFolder = new Folder("設計模式資料");
        File note1 = new File("組合模式筆記.md", "組合模式組合多個物件形成樹形結構以表示具有 \"整體—部分\" 關係的層次結構");
        File note2 = new File("工廠方法模式.md", "工廠方法模式定義一個用於建立物件的介面,讓子類決定將哪一個類例項化。");
        DSFolder.add(note1);
        DSFolder.add(note2);

        Folder codeFolder = new Folder("樣例程式碼");
        File readme = new File("README.md", "# 設計模式示例程式碼專案");
        Folder srcFolder = new Folder("src");
        File code1 = new File("組合模式示例.java", "這是組合模式的示例程式碼");

        srcFolder.add(code1);
        codeFolder.add(readme);
        codeFolder.add(srcFolder);
        DSFolder.add(codeFolder);

        DSFolder.print();
    }
}
複製程式碼

輸出結果

設計模式資料
組合模式筆記.md
工廠方法模式.md
樣例程式碼
README.md
src
組合模式示例.java
複製程式碼

輸出正常,不過有個小問題,從輸出看不出它們的層級結構,為了體現出它們之間的層級關係,我們需要改造一下 Folder 類,增加一個 level 屬性,並修改 print 方法

public class Folder extends Component {
    private String name;
    private List<Component> componentList = new ArrayList<Component>();
    public Integer level;

    public Folder(String name) {
        this.name = name;
    }

    @Override
    public String getName() {
        return this.name;
    }

    @Override
    public void add(Component component) {
        this.componentList.add(component);
    }

    @Override
    public void remove(Component component) {
        this.componentList.remove(component);
    }

    @Override
    public void print() {
        System.out.println(this.getName());
        if (this.level == null) {
            this.level = 1;
        }
        String prefix = "";
        for (int i = 0; i < this.level; i++) {
            prefix += "\t- ";
        }
        for (Component component : this.componentList) {
            if (component instanceof Folder){
                ((Folder)component).level = this.level + 1;
            }
            System.out.print(prefix);
            component.print();
        }
        this.level = null;
    }
}
複製程式碼

現在的輸出就有相應的層級結構了

設計模式資料
	- 組合模式筆記.md
	- 工廠方法模式.md
	- 樣例程式碼
	- 	- README.md
	- 	- src
	- 	- 	- 組合模式示例.java
複製程式碼

我們可以畫出它們之間的類圖

示例.組合模式類圖

在這裡父類 Component 是一個抽象構件類,Folder 類是一個容器構件類,File 是一個葉子構件類,Folder 和 File 繼承了 Component,Folder 與 Component 又是聚合關係

透明與安全

在使用組合模式時,根據抽象構件類的定義形式,我們可將組合模式分為透明組合模式和安 全組合模式兩種形式。

透明組合模式

透明組合模式中,抽象構件角色中宣告瞭所有用於管理成員物件的方法,譬如在示例中 Component 宣告瞭 addremove 方法,這樣做的好處是確保所有的構件類都有相同的介面。透明組合模式也是組合模式的標準形式。

透明組合模式的缺點是不夠安全,因為葉子物件和容器物件在本質上是有區別的,葉子物件不可能有下一個層次的物件,即不可能包含成員物件,因此為其提供 add()remove() 等方法是沒有意義的,這在編譯階段不會出錯,但在執行階段如果呼叫這些方法可能會出錯(如果沒有提供相應的錯誤處理程式碼)

安全組合模式

在安全組合模式中,在抽象構件角色中沒有宣告任何用於管理成員物件的方法,而是在容器構件 Composite 類中宣告並實現這些方法。

安全組合模式模式圖

安全組合模式的缺點是不夠透明,因為葉子構件和容器構件具有不同的方法,且容器構件中那些用於管理成員物件的方法沒有在抽象構件類中定義,因此客戶端不能完全針對抽象程式設計,必須有區別地對待葉子構件和容器構件。

在實際應用中 java.awtswing 中的組合模式即為安全組合模式。

組合模式總結

組合模式的主要優點如下:

  • 組合模式可以清楚地定義分層次的複雜物件,表示物件的全部或部分層次,它讓客戶端忽略了層次的差異,方便對整個層次結構進行控制。
  • 客戶端可以一致地使用一個組合結構或其中單個物件,不必關心處理的是單個物件還是整個組合結構,簡化了客戶端程式碼。
  • 在組合模式中增加新的容器構件和葉子構件都很方便,無須對現有類庫進行任何修改,符合“開閉原則”。
  • 組合模式為樹形結構的物件導向實現提供了一種靈活的解決方案,通過葉子物件和容器物件的遞迴組合,可以形成複雜的樹形結構,但對樹形結構的控制卻非常簡單。

組合模式的主要缺點如下:

  • 使得設計更加複雜,客戶端需要花更多時間理清類之間的層次關係。
  • 在增加新構件時很難對容器中的構件型別進行限制。

適用場景

  • 在具有整體和部分的層次結構中,希望通過一種方式忽略整體與部分的差異,客戶端可以一致地對待它們。
  • 在一個使用面嚮物件語言開發的系統中需要處理一個樹形結構。
  • 在一個系統中能夠分離出葉子物件和容器物件,而且它們的型別不固定,需要增加一些新的型別。

原始碼分析組合模式的典型應用

java.awt中的組合模式

Java GUI分兩種:

  • AWT(Abstract Window Toolkit):抽象視窗工具集,是第一代的Java GUI元件。繪製依賴於底層的作業系統。基本的AWT庫處理使用者介面元素的方法是把這些元素的建立和行為委託給每個目標平臺上(Windows、 Unix、 Macintosh等)的本地GUI工具進行處理。

  • Swing,不依賴於底層細節,是輕量級的元件。現在多是基於Swing來開發。

我們來看一個AWT的簡單示例:

注意:為了正常顯示中文,需要在IDEA中的 Edit Configurations -> VM Options 中設定引數 -Dfile.encoding=GB18030

import java.awt.*;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;

public class MyFrame extends Frame {

    public MyFrame(String title) {
        super(title);
    }

    public static void main(String[] args) {
        MyFrame frame = new MyFrame("這是一個 Frame");

        // 定義三個構件,新增到Frame中去
        Button button = new Button("按鈕 A");
        Label label = new Label("這是一個 AWT Label!");
        TextField textField = new TextField("這是一個 AWT TextField!");

        frame.add(button, BorderLayout.EAST);
        frame.add(label, BorderLayout.SOUTH);
        frame.add(textField, BorderLayout.NORTH);

        // 定義一個 Panel,在Panel中新增三個構件,然後再把Panel新增到Frame中去
        Panel panel = new Panel();
        panel.setBackground(Color.pink);

        Label lable1 = new Label("使用者名稱");
        TextField textField1 = new TextField("請輸入使用者名稱:", 20);
        Button button1 = new Button("確定");
        panel.add(lable1);
        panel.add(textField1);
        panel.add(button1);

        frame.add(panel, BorderLayout.CENTER);

        // 設定Frame的屬性
        frame.setSize(500, 300);
        frame.setBackground(Color.orange);
        // 設定點選關閉事件
        frame.addWindowListener(new WindowAdapter() {
            @Override
            public void windowClosing(WindowEvent e) {
                System.exit(0);
            }
        });
        frame.setVisible(true);
    }
}
複製程式碼

執行後窗體顯示如下

示例.AWT繪製窗體

我們在Frame容器中新增了三個不同的構件 ButtonLabelTextField,還新增了一個 Panel 容器,Panel 容器中又新增了 ButtonLabelTextField 三個構件,為什麼容器 FramePanel 可以新增型別不同的構件和容器呢?

我們先來看下AWT Component的類圖

AWT Component類圖

GUI元件根據作用可以分為兩種:基本元件和容器元件。

  • 基本元件又稱構件,諸如按鈕、文字框之類的圖形介面元素。
  • 容器是一種比較特殊的元件,可以容納其他元件,容器如視窗、對話方塊等。所有的容器類都是 java.awt.Container 的直接或間接子類

容器父類 Container 的部分程式碼如下

public class Container extends Component {
    /**
     * The components in this container.
     * @see #add
     * @see #getComponents
     */
    private java.util.List<Component> component = new ArrayList<>();
    
    public Component add(Component comp) {
        addImpl(comp, null, -1);
        return comp;
    }
    // 省略...
}
複製程式碼

容器父類 Container 內部定義了一個集合用於儲存 Component 物件,而容器元件 Container 和 基本元件如 ButtonLabelTextField 等都是 Component 的子類,所以可以很清楚的看到這裡應用了組合模式

Component 類中封裝了元件通用的方法和屬性,如圖形的元件物件、大小、顯示位置、前景色和背景色、邊界、可見性等,因此許多元件類也就繼承了 Component 類的成員方法和成員變數,相應的成員方法包括:

&emsp;&emsp;&emsp;getComponentAt(int x, int y)
&emsp;&emsp;&emsp;getFont()
&emsp;&emsp;&emsp;getForeground()
&emsp;&emsp;&emsp;getName()
&emsp;&emsp;&emsp;getSize()
&emsp;&emsp;&emsp;paint(Graphics g)
&emsp;&emsp;&emsp;repaint()
&emsp;&emsp;&emsp;update()
&emsp;&emsp;&emsp;setVisible(boolean b)
&emsp;&emsp;&emsp;setSize(Dimension d)
&emsp;&emsp;&emsp;setName(String name)
複製程式碼

Java集合中的組合模式

HashMap 提供 putAll 的方法,可以將另一個 Map 物件放入自己的儲存空間中,如果有相同的 key 值則會覆蓋之前的 key 值所對應的 value 值

public class Test {
    public static void main(String[] args) {
        Map<String, Integer> map1 = new HashMap<String, Integer>();
        map1.put("aa", 1);
        map1.put("bb", 2);
        map1.put("cc", 3);
        System.out.println("map1: " + map1);

        Map<String, Integer> map2 = new LinkedMap();
        map2.put("cc", 4);
        map2.put("dd", 5);
        System.out.println("map2: " + map2);

        map1.putAll(map2);
        System.out.println("map1.putAll(map2): " + map1);
    }
}
複製程式碼

輸出結果

map1: {aa=1, bb=2, cc=3}
map2: {cc=4, dd=5}
map1.putAll(map2): {aa=1, bb=2, cc=4, dd=5}
複製程式碼

檢視 putAll 原始碼

    public void putAll(Map<? extends K, ? extends V> m) {
        putMapEntries(m, true);
    }
複製程式碼

putAll 接收的引數為父類 Map 型別,所以 HashMap 是一個容器類,Map 的子類為葉子類,當然如果 Map 的其他子類也實現了 putAll 方法,那麼它們都既是容器類,又都是葉子類

同理,ArrayList 中的 addAll(Collection<? extends E> c) 方法也是一個組合模式的應用,在此不做探討

Mybatis SqlNode中的組合模式

MyBatis 的強大特性之一便是它的動態SQL,其通過 if, choose, when, otherwise, trim, where, set, foreach 標籤,可組合成非常靈活的SQL語句,從而提高開發人員的效率。

來幾個官方示例:

動態SQL -- IF

<select id="findActiveBlogLike"  resultType="Blog">
  SELECT * FROM BLOG WHERE state = ‘ACTIVE’ 
  <if test="title != null">
    AND title like #{title}
  </if>
  <if test="author != null and author.name != null">
    AND author_name like #{author.name}
  </if>
</select>
複製程式碼

動態SQL -- choose, when, otherwise

<select id="findActiveBlogLike"  resultType="Blog">
  SELECT * FROM BLOG WHERE state = ‘ACTIVE’
  <choose>
    <when test="title != null">
      AND title like #{title}
    </when>
    <when test="author != null and author.name != null">
      AND author_name like #{author.name}
    </when>
    <otherwise>
      AND featured = 1
    </otherwise>
  </choose>
</select>
複製程式碼

動態SQL -- where

<select id="findActiveBlogLike"  resultType="Blog">
  SELECT * FROM BLOG 
  <where> 
    <if test="state != null">
         state = #{state}
    </if> 
    <if test="title != null">
        AND title like #{title}
    </if>
    <if test="author != null and author.name != null">
        AND author_name like #{author.name}
    </if>
  </where>
</select>
複製程式碼

動態SQL -- foreach

<select id="selectPostIn" resultType="domain.blog.Post">
  SELECT * FROM POST P WHERE ID in
  <foreach item="item" index="index" collection="list"
      open="(" separator="," close=")">
        #{item}
  </foreach>
</select>
複製程式碼

Mybatis在處理動態SQL節點時,應用到了組合設計模式,Mybatis會將對映配置檔案中定義的動態SQL節點、文字節點等解析成對應的 SqlNode 實現,並形成樹形結構。

SQLNode 的類圖如下所示

Mybatis SqlNode 類圖

需要先了解 DynamicContext 類的作用:主要用於記錄解析動態SQL語句之後產生的SQL語句片段,可以認為它是一個用於記錄動態SQL語句解析結果的容器

抽象構件為 SqlNode 介面,原始碼如下

public interface SqlNode {
  boolean apply(DynamicContext context);
}
複製程式碼

applySQLNode 介面中定義的唯一方法,該方法會根據使用者傳入的實參,引數解析該SQLNode所記錄的動態SQL節點,並呼叫 DynamicContext.appendSql() 方法將解析後的SQL片段追加到 DynamicContext.sqlBuilder 中儲存,當SQL節點下所有的 SqlNode 完成解析後,我們就可以從 DynamicContext 中獲取一條動態生產的、完整的SQL語句

然後來看 MixedSqlNode 類的原始碼

public class MixedSqlNode implements SqlNode {
  private List<SqlNode> contents;

  public MixedSqlNode(List<SqlNode> contents) {
    this.contents = contents;
  }

  @Override
  public boolean apply(DynamicContext context) {
    for (SqlNode sqlNode : contents) {
      sqlNode.apply(context);
    }
    return true;
  }
}
複製程式碼

MixedSqlNode 維護了一個 List<SqlNode> 型別的列表,用於儲存 SqlNode 物件,apply 方法通過 for迴圈 遍歷 contents 並呼叫其中物件的 apply 方法,這裡跟我們的示例中的 Folder 類中的 print 方法非常類似,很明顯 MixedSqlNode 扮演了容器構件角色

對於其他SqlNode子類的功能,稍微概括如下:

  • TextSqlNode:表示包含 ${} 佔位符的動態SQL節點,其 apply 方法會使用 GenericTokenParser 解析 ${} 佔位符,並直接替換成使用者給定的實際引數值
  • IfSqlNode:對應的是動態SQL節點 <If> 節點,其 apply 方法首先通過 ExpressionEvaluator.evaluateBoolean() 方法檢測其 test 表示式是否為 true,然後根據 test 表示式的結果,決定是否執行其子節點的 apply() 方法
  • TrimSqlNode :會根據子節點的解析結果,新增或刪除相應的字首或字尾。
  • WhereSqlNodeSetSqlNode 都繼承了 TrimSqlNode
  • ForeachSqlNode:對應 <foreach> 標籤,對集合進行迭代
  • 動態SQL中的 <choose><when><otherwise> 分別解析成 ChooseSqlNodeIfSqlNodeMixedSqlNode

綜上,SqlNode 介面有多個實現類,每個實現類對應一個動態SQL節點,其中 SqlNode 扮演抽象構件角色,MixedSqlNode 扮演容器構件角色,其它一般是葉子構件角色

參考:
劉偉:設計模式Java版
慕課網java設計模式精講 Debug 方式+記憶體分析
Java AWT基礎及佈局管理
【java原始碼一帶一路系列】之HashMap.putAll()
徐郡明:Mybatis技術內幕 3.2 SqlNode&SqlSource
Mybatis 3.4.7 文件:動態 SQL

相關文章