設計模式之訪問者模式

好記性不如爛筆頭,在java逐漸框架化,很多底層的思想和原理不被熟知,但是其重要性不言而喻,習其表面不如學其根本

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

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

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

package com.yitian.visitor;

import java.time.LocalDate;

/**
 * @Description 各種商品的實體類,包括糖、酒、和水果,它們都應該共享一些共通屬性,那就先抽象出一個商品類
 * @Author yitianRen
 * @Date 2019/8/30 14:02
 * @Version 1.0
 **/
public abstract  class Product {
    protected String name;// 品名
    protected LocalDate producedDate;// 生產日期
    protected float price;// 價格

    public Product(String name, LocalDate producedDate, float price) {
        this.name = name;
        this.producedDate = producedDate;
        this.price = price;
    }

    public String getName() {
        return name;
    }

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

    public LocalDate getProducedDate() {
        return producedDate;
    }

    public void setProducedDate(LocalDate producedDate) {
        this.producedDate = producedDate;
    }

    public float getPrice() {
        return price;
    }

    public void setPrice(float price) {
        this.price = price;
    }
}

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

package com.yitian.visitor;

import java.time.LocalDate;

/**
 * @Description 水果多屬性重量
 * @Author yitianRen
 * @Date 2019/8/30 14:30
 * @Version 1.0
 **/
public class Fruit extends Product{
    private float weight;

    public Fruit(String name, LocalDate producedDate, float price, float weight) {
        super(name, producedDate, price);
        this.weight = weight;
    }

    public float getWeight() {
        return weight;
    }

    public void setWeight(float weight) {
        this.weight = weight;
    }

   
}
package com.yitian.visitor;

import java.time.LocalDate;

/**
 * @Description 糖果類
 * @Author yitianRen
 * @Date 2019/8/30 14:05
 * @Version 1.0
 **/
public class Candy extends Product{
    public Candy(String name, LocalDate producedDate, float price) {
        super(name, producedDate, price);
    }

   
}
package com.yitian.visitor;

import java.time.LocalDate;

/**
 * @Description 酒
 * @Author yitianRen
 * @Date 2019/8/30 14:29
 * @Version 1.0
 **/
public class Wine extends Product{
    public Wine(String name, LocalDate producedDate, float price) {
        super(name, producedDate, price);
    }

    
}

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

package com.yitian.visitor;

/**
 * @Author:yitianRen
 * @Date:14:38 2019/8/30
 * Description:(訪問者)
 */
public interface Visitor {

    void visit(Candy candy);// 糖果重載方法

    void visit(Wine wine);// 酒類重載方法

    void visit(Fruit fruit);// 水果重載方法
}

針對不同商品有不同的折扣和處理方式

package com.yitian.visitor;

import java.text.NumberFormat;
import java.time.LocalDate;

/**
 * @Description 針對不同的商品有不同的折扣
 * @Author yitianRen
 * @Date 2019/8/30 14:42
 * @Version 1.0
 **/
public class DiscountVisitor implements Visitor {
    private LocalDate billDate;

    public DiscountVisitor(LocalDate billDate) {
        this.billDate = billDate;
        System.out.println("結算日期:" + billDate);
    }

    @Override
    public void visit(Candy candy) {
        System.out.println("=====糖果【" + candy.getName() + "】打折後價格=====");
        float rate = 0;
        long days = billDate.toEpochDay() - candy.getProducedDate().toEpochDay();
        if (days > 180) {
            System.out.println("超過半年過期糖果,請勿食用!");
        } else {
            rate = 0.9f;
        }
        float discountPrice = candy.getPrice() * rate;
        System.out.println(NumberFormat.getCurrencyInstance().format(discountPrice));
    }

    @Override
    public void visit(Wine wine) {
        System.out.println("=====酒品【" + wine.getName() + "】無折扣價格=====");
        System.out.println(NumberFormat.getCurrencyInstance().format(wine.getPrice()));
    }

    @Override
    public void visit(Fruit fruit) {
        System.out.println("=====水果【" + fruit.getName() + "】打折後價格=====");
        float rate = 0;
        long days = billDate.toEpochDay() - fruit.getProducedDate().toEpochDay();
        if (days > 7) {
            System.out.println("¥0.00元(超過一週過期水果,請勿食用!)");
        } else if (days > 3) {
            rate = 0.5f;
        } else {
            rate = 1;
        }
        float discountPrice = fruit.getPrice() * fruit.getWeight() * rate;
        System.out.println(NumberFormat.getCurrencyInstance().format(discountPrice));
    }

}

上面是不同商品的計算模式,下面看一下客戶端輸出結果

import com.yitian.visitor.Candy;
import com.yitian.visitor.DiscountVisitor;
import com.yitian.visitor.Visitor;

import java.time.LocalDate;

public class Main {

    public static void main(String[] args) {
        //小黑兔奶糖,生產日期:2018-10-1,原價:¥20.00
               Candy candy = new Candy("小黑兔奶糖", LocalDate.of(2018, 10, 1), 20.00f);
               Visitor discountVisitor = new DiscountVisitor(LocalDate.of(2019, 1, 1));
               discountVisitor.visit(candy);
        /*結算日期:2019-01-01
                =====糖果【小黑兔奶糖】打折後價格=====
        ¥18.00*/
    }
}

單個商品這種輸出無可厚非,抽象類會自動識別重載方法,但是如果我們購買多個商品呢?讓我來看一下:

import com.yitian.visitor.*;

import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;

public class Main {

    public static void main(String[] args) {
        /// 三件商品加入購物車
        List<Product> products = Arrays.asList(
                new Candy("小黑兔奶糖", LocalDate.of(2018, 10, 1), 20.00f),
                new Wine("貓泰白酒", LocalDate.of(2017, 1, 1), 1000.00f),
                new Fruit("草莓", LocalDate.of(2018, 12, 26), 10.00f, 2.5f)
        );

        Visitor discountVisitor = new DiscountVisitor(LocalDate.of(2018, 1, 1));
        // 迭代購物車輪流結算
        for (Product product : products) {
            discountVisitor.visit(product);// 此處報錯
        }
    }
}

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

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

package com.yitian.visitor;

/**
  *@Author:yitianRen
  *@Date:10:08 2019/9/10
  *Description:  接待者  雙派發
  */
public interface Acceptable {
    //主動接受拜訪者
    void  accept(Visitor visitor);
}

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

package com.yitian.visitor;

import java.time.LocalDate;

/**
 * @Description 糖果類
 * @Author yitianRen
 * @Date 2019/8/30 14:05
 * @Version 1.0
 **/
public class Candy extends Product implements Acceptable{
    public Candy(String name, LocalDate producedDate, float price) {
        super(name, producedDate, price);
    }

    @Override
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }
}

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

package com.yitian.visitor;

import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;

/**
 * @Description TODO
 * @Author yitianRen
 * @Date 2019/9/10 10:17
 * @Version 1.0
 **/
public class VisitorMain {

    public static void main(String[] args) {
        // 三件商品加入購物車  使用接口接待不同類型商品調用各自折扣
        List<Acceptable> products = Arrays.asList(
                new Candy("小黑兔奶糖", LocalDate.of(2018, 10, 1), 20.00f),
                new Wine("貓泰白酒", LocalDate.of(2017, 1, 1), 1000.00f),
                new Fruit("草莓", LocalDate.of(2018, 12, 26), 10.00f, 2.5f)
        );

        Visitor discountVisitor = new DiscountVisitor(LocalDate.of(2019, 1, 1));
        for (Acceptable product : products) {
            product.accept(discountVisitor);

        }
        /*
        結算日期:2019-01-01
        =====糖果【小黑兔奶糖】打折後價格=====
        ¥18.00
        =====酒品【貓泰白酒】無折扣價格=====
        ¥1,000.00
        =====水果【草莓】打折後價格=====
        ¥12.50
        */
    }

}

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

 

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

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

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

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

文章來源於 微信公衆號  java知音  設計模式

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