戲說領域驅動設計(廿三)——工廠

SKevin發表於2022-04-18

  在講解實體的章節中我們曾經介紹說過如何有效的建立實體,主要包括兩種方式:工廠和建構函式。本章我們工廠進行一下詳解,這種東西能有效的簡化實體使用的難度,畢竟你無法通過Spring這種容器來管理領域物件。實際的開發過程中,工廠的使用要比書中的講解會複雜一點,所以在本章我會對實踐中遇到的一些問題以及使用什麼樣的模式去應對給出一些建議。

一、工廠的作用

  學習過設計模式的人都應該知道“工廠模式”,尤其是其中的“簡單工廠”,感覺就沒什麼可學的,太簡單了。但在DDD中,工廠卻比較常用,不過也正像書上說的一樣,其實算不上一等公民,畢竟其承擔的責任只是實體的建立,有點偏技術。但反過來說,少了這麼一個東西還真不行,有些實體的建立起來很費勁,大部分情況下只有實體設計人能完全搞定,出現了知識壟斷的情況。可是在真實的工作中,我們需要團隊協作,也會出現人員更迭的情況,出現這種壟斷並不是什麼好事兒。此外,作為設計者,讓自己研發出來的東西特別難以使用,這本身其實是失敗的。看看Spring框架,你就知道人家工程師的牛掰之處了,我們不管其內部如何複雜,你就告訴我使用起來是不是很方便吧?我這裡有個小經驗與大家分享:不論是做後臺的程式碼還是前端的功能,都把自己假設成為使用者,你就會在設計過程中自然而然的考慮易用性和安全性了。當然,也不排除有些不願意思考的人,不過是自廢前程而矣。將自己當成使用者還有另外一個好處:之所以叫使用者,就代表你不能對他做任何假設,只要你提供出去功能就代表是可用的,把自己當成使用者正好可以檢驗程式碼中是否存在不妥之處。之前我們說過實體的不變條件,當把客戶作為不可信任物件看待的時候,你就會在設計過程中增加約束來避免破壞不變性的情況出現。

  扯扯就遠了,看看下面這段程式碼,這是我在實際的專案中所設計的一個實體。前面我曾經說過,實體中必須包含一個可以讓所有屬性得到有效賦值的建構函式,因為保障它的完整性和不變條件是在實體設計過程中需要遵守的重要原則。

public class DeploymentApprovalForm extends ApprovalFormBase {

    DeploymentApprovalForm(Long id, String name, ApplierInfo applierInfo, LocalDateTime createdDate, LocalDateTime updatedDate,
                           List<ApprovalNodeBase> nodes, LocalDateTime deploymentDate, ProcessStatus status,
                           PhaseType currentPhase, String service, ApplyType applyType) {
        super(id, name, applierInfo, createdDate, updatedDate, nodes);
        if (status != null) {
            this.status = status;
        }
        this.deploymentDate = deploymentDate;
        if (currentPhase != null && currentPhase != PhaseType.UNKNOWN) {
            this.currentPhase = currentPhase;
        }
        this.changeService(service);
        this.applyType = applyType;
        if (applyType == null || applyType == ApplyType.UNKNOWN) {
            this.applyType = ApplyType.FORMAL;
        }
    }
}

  我如果直接把這樣的設計給其它程式設計師使用,保準被罵爹!這個物件的構造太複雜了,你需要了解每一個引數是如何構造了。簡單型別還好,其中還包含了許多的值物件,使用人需要了解每一個值物件的構造方式和理,別跟我說使用Spring 的IoC,這可是領域物件。其實也不是故意要寫成這樣,業務複雜的情況實體也不可能簡單了,要不然誰還用OOP,整個程式導向不是挺香的嗎?您其實不需要考慮上述程式碼是什麼含義,只需要關注其建構函式即可。之所以給出這段程式碼,是想向您證明我們本章的主題:雖然工廠不是一等公平,但不代表其不重要。當然了,你可能會抬槓說沒有工廠就不能建立物件了?也不是不行,成本高啊。如果這段程式碼是別人寫的,現在你要用,我就問你是不是得問對方怎麼搞,沒人可問的話你是不是需要自己把程式碼都看一遍?一個實體這樣幹可以,十個呢?百個呢?這不是工作,是自虐!針對上述程式碼,您可能還會說可以使用檢視模型作為引數,相當於把建構函式作為工廠來使用。這種情況下的確可以隱藏物件建立細節,不過領域模型主要是用於為某個業務的執行進行支撐,過重的建構函式從另一方面又增加了其責任。另外就是程式碼量很大,反正我覺得這樣做不好,單一責任原則其實是值得遵守的。

  迴歸正題,對於上面的反例,相信在此刻我根本不需要再解釋引入工廠的好處,事實已經證明了。這樣的場景我相信您在實踐中肯定遇到過,而且不會少,那麼要如何使用工廠,請繼續跟著我的腳步前行。

二、工廠使用模式

  工廠模式的使用有三種,您可別一見到工廠就以為需要建立一個“*Factory”的類,這種方式的確比較常用,但並不是全部。不同的場景需要使用不同的方法,畢竟我們考慮問題的時候不能太過於狹隘,實現情況還是很複雜的。

1、實體包含工廠方法

  一種經常被使用的方式是在實體中加入用於建立該實體的靜態方法,如下面程式碼片段所示。在實體不那麼複雜的情況下,這種方式其實可以接受,雖然說這樣會造成實體承擔了過多的責任,不過在實踐中有些模稜兩可的規則是可以打破。您完全可以新建一個單獨的類,責任雖然單一了,可又多了一個類檔案,維護起來也是需要成本的。

public class Order extends EntityModel<Long> {
    private String name;

    public static Order create(OrderVo orderInfo) {
        ……
    }
}

  另外一種方式是通過實體中的業務方法建立另外的實體,這種方法最常見於領域事件的建立,如下程式碼片段所示。此種方式所帶來的好處是其有效的表達出了所謂的通用語言,直白來說就是反應了業務術語。我早期寫程式碼的時候謹遵一個模式:命令型方法無返回值,我記得應該是在《程式碼大全》中有過類似的說明。所以遇到需要使用事件的場景,都是在應用服務中進行構造。近兩年則使用類似下面這種方式,這程式碼看起來多麼優雅,所以各位看君切莫像我一樣陷入教條主義。

public class Order extends EntityModel<Long> {
    private OrderStatus staus;

    public OrderPaid pay(Money fee) {
        this.status = OrderStatus.PAID;
        return new OrderPaid(this.getId());
    }
}

  什麼?你懷疑我水文字,上述的案例看不出來哪裡反應了通用語言?較勁唄?那我就再整一個。我曾經設計過一個類似工作流的東西,叫作“業務申請單”,你也不管到底申請什麼的,反正有申請就會涉及到審批,需求中說明“每次審批的操作都需要記錄操作結果,使用者可以檢視某個審批單的所有操作記錄”。下面為部分程式碼的片段,通過示例您可以看到“ApprovalFormBase”實體的“approve”方法在業務執行完結後返回一個“審批記錄”實體,這裡它不僅承擔了工廠的作用,也表達了業務意圖。說到這份兒應該不能算是水文字了吧?

public abstract class ApprovalFormBase extends EntityModel<Long> {
    private ApprovalNodeGroup nodeGroup = new ApprovalNodeGroup();
    
    public ApprovalRecord approve(Advice advice) throws ApprovalFormOperationException {
        this.throwExceptionIfTerminatedOrInvalidated();
        if (advice == null) {
            throw new ApprovalFormOperationException(OperationMessages.INVALID_APPROVAL_INFO);
        }
        ……
        return this.nodeGroup.approve(approvalContext, advice);
    }
}

2、實體的子類作為工廠

  這種方式在本系列的第十六章中介紹過,相對來說也比較優雅,雖然多出來一個新的檔案。方便起見,我還是把程式碼再貼一下並稍微多做一些解釋。“Order”程式碼中,我將其建構函式設計為“protected”,這樣就可以限制住不經過工廠而建立其例項的情況。另外,這種方式也可以讓您在工廠類中呼叫一些父類的方法,實踐中此等應用場景並不多見,因為工廠的職責只能用於實體的例項化不應承擔業務規則,不過也讓我們在開發工作中遇到某些需要抉擇的場景時多了一個選擇。

public class Order extends EntityModel<Long> {
    private String name;
    private Contact contact;

    protected Order(Long id, String name, Contact contact) throws OrderCreationException {
        super(id);
        this.name = name;
        this.contact = contact;
    }
}

final public class OrderFactory extends Order {
    public static Order create(OrderVO orderInfo) throws OrderCreationException {
        if (orderInfo == null) {
            throw new OrderCreationException();
        }
        Contact contact = new Contact(orderInfo.getEmail(), orderInfo.getName());

        return new Order(0L, orderInfo.getName(), contact);
    }
}

3、業務服務類作為工廠

  業務服務類作為工廠其實類似於上面的工廠子類,只是這種工廠並不會從某個實體繼承。這種方式其實在實踐中比較常用,因為夠直觀。雖然我們通常會採用“*Factory”這樣的命名方式,但其本質上是一個領域服務(回想一下領域服務的使用規則)。通常情況下,我們工廠服務存在兩個使用模式:一是簡單領域實體工廠,此種模式使用方式簡單明瞭,一目瞭然,請參看如下程式碼。此處請您務必注意一下,下面的程式碼片段僅僅是為演示用,真實的場景下程式碼相對要複雜一點,本章後面部分我會著重以此說明;工廠服務另外的一個模式使用起來簡單,不過其具備較強的業務含義,下一節我會對此做詳細解釋。不過在繼續之前,我們給下面這種工廠一個名字以方便後面引用,就叫其為“實體工廠”吧。

final public class OrderFactory{
    public final static OrderFactory INSTANCE = new OrderFactory();
    
    private OrderFactory() {
        
    }
    
    public Order create(OrderVO orderInfo) throws OrderCreationException {
        if (orderInfo == null) {
            throw new OrderCreationException();
        }
        Contact contact = new Contact(orderInfo.getEmail(), orderInfo.getName());

        return new Order(0L, orderInfo.getName(), contact);
    }
}

  工廠服務的第二個模式在命名上一般不會使用“*Factory”模式,而是使用“*Service”代替之,其包含的建立型方法基本上只用於構造新的物件;而“實體工廠”除了此項責任外還會用於實體資料反序列化後的構造。為方便起見,我們給第二個模式所描述的工廠一個新的名稱“工廠服務”,下面我們來著重介紹一下“工廠服務”的使用。

  舉一個例子更能說明問題,這個業務很簡單:訂單項需要包含要購買的商品資訊。通過名字您可以看出來“訂單項”與“商品”肯定屬於兩個不同的限界上下文:一個是訂單BC,一個是銷售品BC。兩個限界上下文間只能通過什麼物件來傳遞資訊來著?“檢視模型”,千萬別忘了。訂單項是一個領域模型,從銷售品限界上下文傳過來的資訊是一個檢視模型,這兩個物件不能放在一起,這個應該不會有疑問吧?此外,銷售品域中的銷售品資訊屬性非常多比如“規格”、“生產廠商”、“質量保證資訊”等,但傳到訂單域後也就一兩種是被使用的。您也見天兒在淘寶或京東買東西,沒見訂單項中包含生產廠家、詳細規格等資訊吧?這些根本就不是訂單項所關注的內容,它所在意的是:產品名稱、價格。假如我們在深入想一想,你所買的東西在銷售品域中其實不能被稱之為“商品”的,它還沒被銷售出去,叫商品不合適;而到了訂單域後,它已經被訂購了,此刻才能真正的被稱之為商品。當然了,“商品”也好、“銷售品”也好,叫什麼聽領域專家的,這是人為的規定,案例中的叫法也只是為了演示效果。其實類似的例子我在前面已經舉過,即“訂單和客戶資訊的領域模型設計”。之所以再拿出來說明,是想讓您在設計過程中要注意通用語言的使用以及從始至終都通過業務來驅動領域模型設計的工作思路。其實通用語言這個概念挺虛的,您只需要遵守如下原則:在設計過程中仔細考慮領域模型的命名,這個命名一旦在溝通中使用,大家就會明白其具體指向的是什麼;通過閱讀程式碼也能知曉某個實體所指代的領域物件。對於上面的需求,我們的程式碼可以寫成下面這樣。

final public class GoodsCreatorService {
    public final static GoodsCreator INSTANCE = new GoodsCreator();
    
    private GoodsCreator() {
        
    }
    
    public List<Goods> create(List<ProductVO) products) {
        return products.stream()
            .map(e -> new Goods(e.getName(), e.getID()))
            .collect(Collectors.toList());
    }
}

  在上面的程式碼中,“create”方法的引數“products”由應用服務呼叫銷售品BC介面卡獲取並傳入到“GoodsCreatorService”中,請務必別忘了這是一個領域服務,不要讓其直接呼叫基礎設施層的介面卡。

三、實體工廠實踐

  我特意把“實體工廠”的設計提取出來,是因為在實踐中需要關注工廠的構建方法所適用的場景,並不是只有一個如“create”或“build”方法就能搞定的。前面我們說過,實體的建立有兩個場景:一是根據外部資訊從無到有的建立;二是根據資料庫資訊反序列化。雖然本質上都是進行實體的建立,但由於場景不同,其實現思路也不一樣,讓我們仔細的說。

  新建實體時我們有時會根據業務需要硬性的給某個實體屬性一個預設值;構建過程中如果外部資訊不全,我們也可能需要給其某個屬性一個預設值,比如下面的程式碼片段。這段程式碼展示了:1)新建訂單時將其狀態強制設定為“待支付”;2)“是否需要發票”屬性如未在引數中包含資訊則預設為“否”。這段程式碼看起來沒有錯誤,但不能用於實體反序列化時,否則每次從資料庫反序列化後訂單的狀態都是“待支付”。實體序列化後必然會涉及反序列化的過程,除非你只序列一次,那不就成了日誌了嗎?

final public class OrderFactory {
    public static Order create(OrderVO orderInfo) throws OrderCreationException {
        if (orderInfo == null) {
            throw new OrderCreationException();
        }
     status = OrderStatus.WAIT_PAY;
        boolean needFapiao = false;
        if (orderInfo.needFapiao() != null) {
            needFapiao = true;
        }
    
        return new Order(0L, status, needFapiao);
    }
}

public enum OrderStatus {
    public static OrderStatus of(Integer status) {
        if (status == null) {
            return OrderStatus.UNKNOWN
        }
    }
}

  我其實等著您回懟呢,你可能會說“你這程式碼是騙人的,我可以首先判斷傳入的狀態資訊是否為空,為空時我再設定預設值;不為空我就使用傳入的值”,也就是下面這段程式碼。其實這段程式碼才會有潛在的問題:如果某個工程師手欠,把資料庫中訂單“狀態”列的值變成了“null”,這種訂單從資料庫反序列化後會出現什麼結果?實際上從資料的層面來看已經違反了業務的約束,這種物件在建立過程中應該報錯。但如果按下面程式碼的方式,往小了看是一個Bug,往大了看可能會引發更多的賬務問題或投訴。實踐中,如果物件屬性多、建立複雜時,建立過程可能會引發比較大的問題。看得到的還能及時處理,那些潛在的問題才是致命的。此等情況下簡單的使用上面的實體工廠肯定不行,親愛的螢幕前的您,何解?

final public class OrderFactory {
    public static Order create(OrderVO orderInfo) throws OrderCreationException {
        if (orderInfo == null) {
            throw new OrderCreationException();
        }
        OrderStatus status = OrderStatus.of(orderInfo.getStatus());
        if (status == OrderStatus.UNKNOWN) {
            status = OrderStatus.WAIT_PAY;
        }        
    
        return new Order(0L, status, needFapiao);
    }
}

public enum OrderStatus {
    public static OrderStatus of(Integer status) {
        if (status == null) {
            return OrderStatus.UNKNOWN
        }
    }
}

  在說出答案前我其實挺想展示一下在實際專案中工廠方法的複雜度的真實情況,不過貼出這些案例反而會影響我們敘述的思路。所以我先針對上述的問題給出解決方案:既然建立物件會出現在兩個場景中即新建和載入,而我們期望實體的建立不論針對哪種場景最好都通過一個工廠來完成。那我們就索性為每個場景都建立一個單獨的方法並統一放到一個工廠物件中,如下程式碼所示。這是一個實體工廠的基類,我們定義了兩個用於實體建立的方法。當然,您也可以根據需要決策是否建立這樣的基類,因為我們更強調思想的正確。

public abstract class EntityFactoryBase<TEntity extends EntityModel, TParameter extends VOBase> {
    protected abstract TEntity create(TParameter modelInfo) throws OrderCreationException;
    
    protected abstract TEntity load(TParameter modelInfo) throws OrderCreationException;
}

  別震驚啊,就這麼簡單,這裡唯一的約束是:你在建立或從持久化設施載入領域實體的時候,引數應該是“檢視模型”。因為工廠主要就是為了應對複雜場景而存在的,你構造一個物件就三個引數,要毛線的工廠啊。方法的實現我不給程式碼了,“create”和前面的示例一樣,可做一些初始化或預設值的工作;“load”方法,根據傳入的引數(這些引數來源於持久化設施,查詢出來後將資料模型轉換為檢視模型),不做任何的預設值設定。要不還是寫一下“load”吧,免得您說我只打嘴炮兒。

final public class OrderFactory extends EntityFactoryBase<Order, OrderVO> {
    public final static OrderFactory INSTANCE = new OrderFactory();
    
    public Order load(OrderVO orderInfo) throws OrderCreationException {
        if (orderInfo == null) {
            throw new OrderCreationException();
        }        
     //程式碼省略
        return new Order(0L, orderInfo.getStatus());
    }
}

public class OrderRepository {
    private OrderMapper orderMapper;
    
    public Order findBy(Long id) {
        OrderDataEntity entity = this.orderMapper.getById(id);
        OrderVO orderInfo = OrderVO.of(entity);
        
        return OrderFactory.INSTANCE.load(orderInfo);
    }
}

  上述的解決方案其實很簡單,您在使用的時候完全可以使用不同的方式。我之所以特意提出是因為在真實的專案中經常會有這樣的問題而且你繞不開。我們寫這一系列文章當然不能別人寫什麼我就寫什麼,我喜歡把現實中自己遇到的一些問題都丟擲來,為解決問題提供一種思路。當然了,程式碼肯定不是真實的,是因為我故意為之,想通過一些大家喜聞樂見的案例把思想描繪清楚。如果貼一些專案程式碼,由於您沒有需求背景,反而為學習增加了負擔。

總結

  本章主要講解了工廠,不用提它是否能對應統一語言,僅就能簡化領域模型的建立你就值得擁有。著重說明一句,工廠是一種可有可無的元件,具體視您的領域模型的複雜度。實踐中,基本上一個聚合都會有一個工廠對應的,畢竟能夠成為實體的東西其構造過程也簡單不了。

 

附:本節寫得不好,可能是受工作影響比較大,心態不太理想。無論你多麼努力與追求上進,面對權力時不得不進行妥協。本來想踏實的做一些東西,奈何樹欲靜而風不止,可悲。雖說“人有凌雲之志非運不能騰達”,不過這個運到底什麼時候到來????

相關文章