作者:凸凹裏歐
來源:Java知音

衆所周知,對於數據的封裝我們通常會用到POJO類,它除了getter和setter之外是不包含任何業務邏輯的,也就是說它只對應一組數據並不包含任何功能。舉個最常見的例子,比如數據庫對應的實體類,一般我們不會在類裏封裝上業務邏輯,而是放在專門的Service類裏去處理,也就是Service作爲拜訪者去訪問實體類封裝的數據。

現在假設有這麼一個場景,我們有很多的實體數據封裝類(各類食品)都要進行一段相同的業務處理(計算價格),而每個實體類對應着不同的業務邏輯(水果按斤賣,啤酒論瓶賣),但我們又不想每個類對應一個業務邏輯類(類太繁多),而是彙總到一處業務處理(結賬臺),那我們應該如何設計呢?


設計模式是什麼鬼(訪問者)


我們就以超市結賬舉例,首先是各種商品的實體類,包括糖、酒、和水果,它們都應該共享一些共通屬性,那就先抽象出一個商品類吧。

 1public abstract class Product {
2
3 protected String name;// 品名
4 protected LocalDate producedDate;// 生產日期
5 protected float price;// 價格
6
7 public Product(String name, LocalDate producedDate, float price) {
8 this.name = name;
9 this.producedDate = producedDate;
10 this.price = price;
11 }
12
13 public String getName() {
14 return name;
15 }
16
17 public void setName(String name) {
18 this.name = name;
19 }
20
21 public LocalDate getProducedDate() {
22 return producedDate;
23 }
24
25 public void setProducedDate(LocalDate producedDate) {
26 this.producedDate = producedDate;
27 }
28
29 public float getPrice() {
30 return price;
31 }
32
33 public void setPrice(float price) {
34 this.price = price;
35 }
36
37}

我們抽象出來的都是些最基本的商品屬性,簡單的數據封裝,標準的POJO類,接下來我們把這些屬性和方法都繼承下來給具體商品類,它們依次是糖果、酒、和水果。

1public class Candy extends Product {// 糖果類
2
3 public Candy(String name, LocalDate producedDate, float price) {
4 super(name, producedDate, price);
5 }
6
7}
1public class Wine extends Product {// 酒類
2
3 public Wine(String name, LocalDate producedDate, float price) {
4 super(name, producedDate, price);
5 }
6
7}
1public class Fruit extends Product {// 水果
2 private float weight;
3
4 public Fruit(String name, LocalDate producedDate, float price, float weight) {
5 super(name, producedDate, price);
6 this.weight = weight;
7 }
8
9 public float getWeight() {
10 return weight;
11 }
12
13 public void setWeight(float weight) {
14 this.weight = weight;
15 }
16
17}

基本沒什麼特別的,除了水果是論斤銷售,所以我們加了個重量屬性,僅此而已。接下來就是我們的結算業務邏輯了,超市規定即將過期的給予一定打折優惠,日常促銷可以吸引更多顧客。


設計模式是什麼鬼(訪問者)


我們思考一下怎樣設計,針對不同商品的折扣力度顯然是不一樣的,其實不止是打折,我們知道過期商品超市不準繼續售賣,但這對於酒類商品又不存在過期問題。這個業務很明顯是針對不同的類要有不同的邏輯反應了,那對於我們所訪問的商品應該加以區分,用instanceof判斷並分流?這顯然太混亂了,代碼裏會充斥着大量if else!那我們定義多個同名方法,以不同的商品參數去分流?沒錯,我們就用重載,首先定義訪問者接口,爲的是日後對訪問者的擴展。

1public interface Visitor {// 訪問者接口
2
3 public void visit(Candy candy);// 糖果重載方法
4
5 public void visit(Wine wine);// 酒類重載方法
6
7 public void visit(Fruit fruit);// 水果重載方法
8
9}

三個重載方法會響應不同的商品類對象,這是一種功能上的多態性。下面來看具體的業務實現類,我們這裏實現一個日常打折並計算最終價格的業務類DiscountVisitor。

 1public class DiscountVisitor implements Visitor {
2 private LocalDate billDate;
3
4 public DiscountVisitor(LocalDate billDate) {
5 this.billDate = billDate;
6 System.out.println("結算日期:" + billDate);
7 }
8
9 @Override
10 public void visit(Candy candy) {
11 System.out.println("=====糖果【" + candy.getName() + "】打折後價格=====");
12 float rate = 0;
13 long days = billDate.toEpochDay() - candy.getProducedDate().toEpochDay();
14 if (days > 180) {
15 System.out.println("超過半年過期糖果,請勿食用!");
16 } else {
17 rate = 0.9f;
18 }
19 float discountPrice = candy.getPrice() * rate;
20 System.out.println(NumberFormat.getCurrencyInstance().format(discountPrice));
21 }
22
23 @Override
24 public void visit(Wine wine) {
25 System.out.println("=====酒品【" + wine.getName() + "】無折扣價格=====");
26 System.out.println(NumberFormat.getCurrencyInstance().format(wine.getPrice()));
27 }
28
29 @Override
30 public void visit(Fruit fruit) {
31 System.out.println("=====水果【" + fruit.getName() + "】打折後價格=====");
32 float rate = 0;
33 long days = billDate.toEpochDay() - fruit.getProducedDate().toEpochDay();
34 if (days > 7) {
35 System.out.println("¥0.00元(超過一週過期水果,請勿食用!)");
36 } else if (days > 3) {
37 rate = 0.5f;
38 } else {
39 rate = 1;
40 }
41 float discountPrice = fruit.getPrice() * fruit.getWeight() * rate;
42 System.out.println(NumberFormat.getCurrencyInstance().format(discountPrice));
43 }
44
45}

業務看上去也許有些複雜,其中構造方法傳入初始化結單日期(第4行),糖果(第10行)的過期日設置爲半年否則按9折出售,酒品(第24行)則沒有過期限制,一律按原價出售,對於水果(第30行)有效期設置爲一週,如果超過3天按半價出售,總之就是三種商品對應不同的計算邏輯。其實我們可以完全忽略業務實現,這裏應該着重於模式的思考,讓我們看看怎樣客戶端訪問數據。

 1public class Client {
2 public static void main(String[] args) {
3 //小黑兔奶糖,生產日期:2018-10-1,原價:¥20.00
4 Candy candy = new Candy("小黑兔奶糖", LocalDate.of(2018, 10, 1), 20.00f);
5 Visitor discountVisitor = new DiscountVisitor(LocalDate.of(2019, 1, 1));
6 discountVisitor.visit(candy);
7 /*打印輸出:
8 結算日期:2019-01-01
9 =====糖果【小黑兔奶糖】打折後價格=====
10 ¥18.00
11 */
12 }
13}

貌似程序運行地很好,業務邏輯沒有問題,最後打了9折。但是,請注意第4行客戶選了一包奶糖並以糖果類定義引用了糖果對象,這麼做當然無可厚非,但試想我們如果選購多種產品並加入購物車List,購物車只認識泛化的Product,對具體品類不得而知,所以這裏應該進行泛化處理,以產品Product定義引用,讓我們選購多件商品實驗下。

 1public class Client {
2 public static void main(String[] args) {
3 // 三件商品加入購物車
4 List products = Arrays.asList(
5 new Candy("小黑兔奶糖", LocalDate.of(2018, 10, 1), 20.00f),
6 new Wine("貓泰白酒", LocalDate.of(2017, 1, 1), 1000.00f),
7 new Fruit("草莓", LocalDate.of(2018, 12, 26), 10.00f, 2.5f)
8 );
9
10 Visitor discountVisitor = new DiscountVisitor(LocalDate.of(2018, 1, 1));
11 // 迭代購物車輪流結算
12 for (Product product : products) {
13 discountVisitor.visit(product);// 此處報錯
14 }
15
16 }
17}

注意重點來了,我們順利地加入購物車並迭代輪流結算每個產品,可是第13行會報錯,編譯器對泛化後的product很是茫然,這到底是糖還是酒?該調用哪個visit方法呢?很多朋友疑問爲什麼不能在運行時根據對象類型動態地派發給對應的重載方法?試想,如果我們新加一個蔬菜產品類Vegetable,但沒有在Visitor里加入其重載方法visit(Vegetable vegetable),那運行起來豈不是更糟糕?所以編譯器提前就應該禁止此種情形通過編譯。

難道我們設計思路錯了?有沒有辦法把產品派發到相應的重載方法?答案是肯定的,這裏涉及到一個新的概念,我們需要利用“雙派發”(double dispatch)巧妙地繞過這個錯誤,既然訪問者訪問不了,我們從被訪問者(產品資源)入手,來看代碼,先定義一個接待者接口。

1public interface Acceptable {
2 // 主動接受拜訪者
3 public void accept(Visitor visitor);
4
5}

可以看到這個“接待者”定義了一個接待方法,凡是“來訪者”身份的都予以接受。我們先用糖果類實現這個接口,並主動接受來訪者的拜訪。

 1public class Candy extends Product implements Acceptable{// 糖果類
2
3 public Candy(String name, LocalDate producedDate, float price) {
4 super(name, producedDate, price);
5 }
6
7 @Override
8 public void accept(Visitor visitor) {
9 visitor.visit(this);// 把自己交給拜訪者。
10 }
11
12}

糖果類順理成章地成爲了“接待者”(其他品類雷同,此處忽略代碼),並把自己(this)交給了來訪者(第9行),這樣繞來繞去起到什麼作用呢?別急,我們先來看雙派發到底是怎樣實現的。

 1public class Client {
2 public static void main(String[] args) {
3 // 三件商品加入購物車
4 List products = Arrays.asList(
5 new Candy("小黑兔奶糖", LocalDate.of(2018, 10, 1), 20.00f),
6 new Wine("貓泰白酒", LocalDate.of(2017, 1, 1), 1000.00f),
7 new Fruit("草莓", LocalDate.of(2018, 12, 26), 10.00f, 2.5f)
8 );
9
10 Visitor discountVisitor = new DiscountVisitor(LocalDate.of(2019, 1, 1));
11 // 迭代購物車輪流結算
12 for (Acceptable product : products) {
13 product.accept(discountVisitor);
14 }
15 /*打印輸出:
16 結算日期:2019-01-01
17 =====糖果【小黑兔奶糖】打折後價格=====
18 ¥18.00
19 =====酒品【貓泰白酒】無折扣價格=====
20 ¥1,000.00
21 =====水果【草莓】打折後價格=====
22 ¥12.50
23 */
24 }
25}

注意看第4行的購物車List已經被改爲泛型Acceptable了,也就是說所有商品統統被泛化且當作“接待者”了,由於泛型化後的商品像是被打了包裹一樣讓拜訪者無法識別品類,所以在迭代裏面我們讓這些商品對象主動去“接待”來訪者(第13行)。這類似於警察(訪問者)辦案時嫌疑人(接待者)需主動接受調查並出示自己的身份證給警察,如此就可以基於個人信息查詢前科並展開相關調查。


設計模式是什麼鬼(訪問者)


如此一來,在運行時的糖果自己是認識自己的,它就把自己遞交給來訪者,此時的this必然就屬糖果類了,所以能得償所願地派發到Visitor的visit(Fruit fruit)重載方法,這樣便實現了“雙派發”,也就是說我們先派發給商品去主動接待,然後又把自己派發回給訪問者,我不認識你,你告訴我你是誰。

終於,我們巧妙地用雙派發解決了方法重載的多態派發問題,如虎添翼,訪問者模式框架至此搭建竣工,之後再添加業務邏輯不必再改動數據實體類了,比如我們再增加一個針對六一兒童節打折業務,加大對糖果類、玩具類的打折力度,而不需要爲每個POJO類添加對應打折方法,數據資源(實現接待者接口)與業務(實現訪問者接口)被分離開來,且業務處理集中化、多態化、亦可擴展。純粹的數據,不應該多才多藝。

相關文章