ที่วัตถุใน CQRS + ES ควรเริ่มต้นอย่างสมบูรณ์: ในตัวสร้างหรือเมื่อใช้เหตุการณ์แรก?


9

ดูเหมือนจะมีข้อตกลงอย่างกว้างขวางในชุมชน OOP ที่ตัวสร้างคลาสไม่ควรปล่อยให้วัตถุบางส่วนหรือไม่เตรียมการอย่างเต็มที่

ฉันหมายถึงอะไรโดย "การเริ่มต้น"? ประมาณพูดที่อะตอมกระบวนการที่จะนำวัตถุที่สร้างขึ้นใหม่ในสภาพที่ทั้งหมดของค่าคงที่ระดับนี้ถือเป็น มันควรเป็นสิ่งแรกที่เกิดขึ้นกับวัตถุ (ควรเรียกใช้เพียงครั้งเดียวต่อวัตถุ) และไม่ควรอนุญาตสิ่งใด ๆ ให้จับวัตถุที่ยังไม่ได้เริ่มต้น (ดังนั้นคำแนะนำบ่อยครั้งในการดำเนินการเริ่มต้นวัตถุในคลาส Constructor ด้วยเหตุผลเดียวกันInitializeวิธีการที่มักจะขมวดคิ้วเมื่อเหล่านี้แตกอะตอมและทำให้เป็นไปได้ที่จะได้รับและใช้วัตถุที่ยังไม่ได้ อยู่ในสถานะที่กำหนดไว้อย่างดี)

ปัญหา:เมื่อรวม CQRS กับการจัดหาเหตุการณ์ (CQRS + ES) ซึ่งการเปลี่ยนแปลงสถานะทั้งหมดของวัตถุถูกจับในชุดลำดับเหตุการณ์ (กระแสเหตุการณ์) ที่สั่งไว้ฉันสงสัยว่าเมื่อวัตถุมาถึงสถานะเริ่มต้นเต็มที่: ในตอนท้ายของตัวสร้างคลาสหรือหลังจากเหตุการณ์แรกถูกนำไปใช้กับวัตถุหรือไม่

หมายเหตุ:ฉันไม่ต้องการใช้คำว่า "มวลรวมราก" หากคุณต้องการให้แทนที่เมื่อใดก็ตามที่คุณอ่าน "วัตถุ"

ตัวอย่างสำหรับการอภิปราย:สมมติว่าแต่ละวัตถุมีการระบุIdค่าทึบแสงบางอย่าง(คิดว่า GUID) กระแสเหตุการณ์ที่เป็นตัวแทนของการเปลี่ยนแปลงสถานะของวัตถุนั้นสามารถระบุได้ในที่เก็บเหตุการณ์ด้วยIdค่าเดียวกัน: (ไม่ต้องกังวลเกี่ยวกับลำดับเหตุการณ์ที่ถูกต้อง)

interface IEventStore
{
    IEnumerable<IEvent> GetEventsOfObject(Id objectId); 
}

สมมติต่อไปว่ามีสองประเภทวัตถุและCustomer ShoppingCartมามุ่งเน้นที่ShoppingCart: เมื่อสร้างแล้วตะกร้าสินค้านั้นว่างเปล่าและต้องเชื่อมโยงกับลูกค้าหนึ่งราย บิตสุดท้ายนั้นเป็นค่าคงที่ของคลาส: ShoppingCartวัตถุที่ไม่เกี่ยวข้องกับ a Customerอยู่ในสถานะที่ไม่ถูกต้อง

ใน OOP แบบดั้งเดิมเราอาจทำแบบนี้ในตัวสร้าง:

partial class ShoppingCart
{
    public Id Id { get; private set; }
    public Customer Customer { get; private set; }

    public ShoppingCart(Id id, Customer customer)
    {
        this.Id = id;
        this.Customer = customer;
    }
}

อย่างไรก็ตามฉันกำลังสูญเสียวิธีการสร้างโมเดลใน CQRS + ES โดยไม่ต้องจบลงด้วยการเริ่มต้นรอตัดบัญชี เนื่องจากการเริ่มต้นอย่างง่าย ๆ นี้เป็นการเปลี่ยนแปลงสถานะอย่างมีประสิทธิภาพจึงไม่จำเป็นต้องสร้างแบบจำลองเป็นเหตุการณ์หรือไม่:

partial class CreatedEmptyShoppingCart
{
    public ShoppingCartId { get; private set; }
    public CustomerId { get; private set; }
}
// Note: `ShoppingCartId` is not actually required, since that Id must be
// known in advance in order to fetch the event stream from the event store.

สิ่งนี้จะต้องเป็นเหตุการณ์แรกในShoppingCartสตรีมเหตุการณ์ของวัตถุใด ๆและวัตถุนั้นจะเริ่มต้นได้เมื่อเหตุการณ์ถูกนำไปใช้

ดังนั้นหากการเริ่มต้นกลายเป็นส่วนหนึ่งของกระแสเหตุการณ์ "การเล่น" (ซึ่งเป็นกระบวนการทั่วไปที่น่าจะใช้งานได้เหมือนกันไม่ว่าจะเป็นCustomerวัตถุหรือShoppingCartวัตถุหรือวัตถุชนิดอื่น ๆ สำหรับเรื่องนั้น) ...

  • ตัวสร้างควรเป็นพารามิเตอร์ที่น้อยกว่าและไม่ทำอะไรเลยปล่อยให้void Apply(CreatedEmptyShoppingCart)วิธีการทำงานทั้งหมด(ซึ่งเหมือนกันกับวิธี frowned-on Initialize())?
  • หรือผู้สร้างควรได้รับกระแสเหตุการณ์และเล่นกลับ (ซึ่งทำให้การเริ่มต้นอะตอมอีกครั้ง แต่หมายความว่าคอนสตรัคเตอร์ของคลาสแต่ละคลาสมีตรรกะ "การเล่นและใช้งาน" ทั่วไปเหมือนกันเช่นการทำซ้ำโค้ดที่ไม่ต้องการ)
  • หรือควรจะมีทั้งตัวสร้าง OOP แบบดั้งเดิม (ดังที่แสดงด้านบน) ที่เริ่มต้นวัตถุอย่างถูกต้องและจากนั้นเหตุการณ์ทั้งหมด แต่เหตุการณ์แรกจะถูกvoid Apply(…)ผูกไว้กับมัน?

ฉันไม่คาดหวังคำตอบเพื่อให้การสาธิตการทำงานอย่างสมบูรณ์; ฉันมีความสุขมากถ้ามีใครสามารถอธิบายได้ว่าเหตุผลของฉันมีข้อบกพร่องหรือการเริ่มต้นวัตถุเป็น "จุดปวด" ในการใช้งาน CQRS + ES หรือไม่

คำตอบ:


3

เมื่อทำ CQRS + ES ฉันไม่ต้องการให้มีผู้สร้างสาธารณะเลย การสร้างรากรวมของฉันควรทำผ่านโรงงาน (สำหรับการก่อสร้างที่ง่ายพอเช่นนี้) หรือผู้สร้าง (สำหรับรากรวมที่ซับซ้อนมากขึ้น)

วิธีการเริ่มต้นวัตถุจริงเป็นรายละเอียดการใช้ OOP "ไม่ได้ใช้เริ่มต้น" -advice เป็น IMHO เกี่ยวกับอินเตอร์เฟซที่สาธารณะ คุณไม่ควรคาดหวังว่าทุกคนที่ใช้รหัสของคุณรู้ว่าพวกเขาจะต้องเรียก SecretInitializeMethod42 (bool, int, string) - นั่นคือการออกแบบ API สาธารณะที่ไม่ดี อย่างไรก็ตามถ้าคลาสของคุณไม่มีตัวสร้างสาธารณะใด ๆ แต่มี ShoppingCartFactory ด้วยวิธีการที่สร้างใหม่ ShoppingCart (สตริง) ดังนั้นการดำเนินการของโรงงานนั้นอาจซ่อนการเริ่มต้น / คอนสตรัคเตอร์ประเภทใดก็ได้ที่ผู้ใช้ของคุณไม่จำเป็นต้องรู้ เกี่ยวกับ (ดังนั้นจึงเป็น API สาธารณะที่ดี แต่ช่วยให้คุณสามารถสร้างวัตถุขั้นสูงได้มากกว่าเบื้องหลัง)

โรงงานได้รับตัวแทนที่ไม่ดีจากคนที่คิดว่ามีพวกเขามากเกินไป แต่ใช้อย่างถูกต้องพวกเขาสามารถซ่อนความซับซ้อนมากมายไว้เบื้องหลัง API สาธารณะที่เข้าใจได้ง่าย อย่ากลัวที่จะใช้มันเป็นเครื่องมือที่ทรงพลังซึ่งสามารถช่วยให้คุณสร้างวัตถุที่ซับซ้อนได้ง่ายขึ้น - ตราบใดที่คุณสามารถใช้รหัสบรรทัดเพิ่มเติม

ไม่ใช่การแข่งขันเพื่อดูว่าใครสามารถแก้ปัญหาด้วยรหัสน้อยที่สุด แต่เป็นการแข่งขันที่ต่อเนื่องว่าใครสามารถสร้าง API สาธารณะที่อร่อยที่สุด! ;)

แก้ไข: การเพิ่มตัวอย่างเกี่ยวกับวิธีการใช้รูปแบบเหล่านี้อาจมีลักษณะ

หากคุณมีคอนสตรัคเตอร์รวม "ง่าย" ที่มีพารามิเตอร์ที่จำเป็นสองสามข้อคุณสามารถไปกับการใช้งานในโรงงานขั้นพื้นฐานบางอย่างตามบรรทัดเหล่านี้

public class FooAggregate {
     internal FooAggregate() { }

     public int A { get; private set; }
     public int B { get; private set; }

     internal Handle(FooCreatedEvent evt) {
         this.A = a;
         this.B = b;
     }
}

public class FooFactory {
    public FooAggregate Create(int a, int b) {
        var evt = new FooCreatedEvent(a, b);
        var result = new FooAggregate();
        result.Handle(evt);
        DomainEvents.Register(result, evt);
        return result;
    }
}

แน่นอนว่าคุณแบ่งการสร้าง FooCreatedEvent อย่างไรในกรณีนี้ขึ้นอยู่กับคุณ เราสามารถสร้างเคสที่มีตัวสร้าง FooAggregate (FooCreatedEvent) หรือมีตัวสร้าง FooAggregate (int, int) ที่สร้างเหตุการณ์ วิธีที่คุณเลือกที่จะแบ่งความรับผิดชอบตรงนี้ขึ้นอยู่กับสิ่งที่คุณคิดว่าดีที่สุดและวิธีการลงทะเบียนกิจกรรมโดเมนของคุณ ฉันมักจะเลือกให้โรงงานสร้างเหตุการณ์ - แต่ก็ขึ้นอยู่กับคุณแล้วเนื่องจากการสร้างกิจกรรมเป็นรายละเอียดการใช้งานภายในที่คุณสามารถเปลี่ยนและปรับโครงสร้างใหม่ได้ตลอดเวลาโดยไม่ต้องเปลี่ยนอินเทอร์เฟซภายนอกของคุณ รายละเอียดที่สำคัญที่นี่คือการรวมไม่มีตัวสร้างสาธารณะและ setters ทั้งหมดเป็นส่วนตัว คุณไม่ต้องการให้ใครใช้มันจากภายนอก

รูปแบบนี้ใช้งานได้ดีเมื่อคุณแทนที่ตัวสร้างมากขึ้นหรือน้อยลง แต่ถ้าคุณมีการสร้างวัตถุขั้นสูงกว่านี้อาจซับซ้อนเกินไปที่จะใช้ ในกรณีนี้ฉันมักจะนำรูปแบบของโรงงานและหันไปใช้รูปแบบตัวสร้างแทน - มักจะมีไวยากรณ์ที่คล่องแคล่วมากขึ้น

ตัวอย่างนี้ถูกบังคับเล็กน้อยเนื่องจากคลาสที่สร้างไม่ซับซ้อนมาก แต่คุณหวังว่าจะสามารถเข้าใจความคิดและดูว่ามันจะช่วยลดภาระงานก่อสร้างที่ซับซ้อนมากขึ้นได้อย่างไร

public class FooBuilder {
    private int a;
    private int b;   

    public FooBuilder WithA(int a) {
         this.a = a;
         return this;
    }

    public FooBuilder WithB(int b) {
         this.b = b;
         return this;
    }

    public FooAggregate Build() {
         if(!someChecksThatWeHaveAllState()) {
              throw new OmgException();
         }

         // Some hairy logic on how to create a FooAggregate and the creation events from our state
         var foo = new FooAggregate(....);
         foo.PlentyOfHairyInitialization(...);
         DomainEvents.Register(....);

         return foo;
    }
}

แล้วคุณก็ใช้มันเหมือน

var foo = new FooBuilder().WithA(1).Build();

และโดยทั่วไปเมื่อฉันหันไปใช้รูปแบบผู้สร้างไม่ใช่แค่สอง ints มันอาจมีรายการของวัตถุที่มีค่าหรือพจนานุกรมบางชนิดอาจมีขนดก อย่างไรก็ตามมันก็ค่อนข้างมีประโยชน์ถ้าคุณมีการรวมกันของพารามิเตอร์ทางเลือกมากมาย

สิ่งที่สำคัญสำหรับการไปทางนี้คือ:

  • วัตถุประสงค์หลักของคุณคือการสร้างวัตถุนามธรรมเพื่อให้ผู้ใช้ภายนอกไม่จำเป็นต้องรู้เกี่ยวกับระบบเหตุการณ์ของคุณ
  • มันไม่สำคัญว่าที่ไหนหรือใครที่ลงทะเบียนเหตุการณ์การสร้างส่วนที่สำคัญคือมันได้รับการลงทะเบียนและคุณสามารถรับประกันได้ว่านอกเหนือจากนั้นมันเป็นรายละเอียดการใช้งานภายใน ทำสิ่งที่เหมาะกับโค้ดของคุณอย่างดีที่สุดอย่าทำตามตัวอย่างของฉันเพราะนี่เป็น "วิธีที่ถูกต้อง" - ทุกอย่าง
  • หากคุณต้องการด้วยวิธีนี้คุณสามารถให้โรงงาน / คลังเก็บของคุณคืนค่าอินเทอร์เฟซแทนคลาสที่เป็นรูปธรรม - ทำให้พวกเขาง่ายต่อการเยาะเย้ยสำหรับการทดสอบหน่วย!
  • นี่เป็นรหัสพิเศษจำนวนมากในบางครั้งซึ่งทำให้หลายคนอายห่างจากมัน แต่มักจะเป็นโค้ดที่ค่อนข้างง่ายเมื่อเทียบกับทางเลือกและมันให้คุณค่าเมื่อคุณต้องเปลี่ยนสิ่งต่าง ๆ ไม่ช้าก็เร็ว มีเหตุผลที่เอริคอีแวนส์พูดถึงโรงงาน / ที่เก็บในหนังสือ DDD ของเขาว่าเป็นส่วนสำคัญของ DDD - เป็นแนวคิดที่จำเป็นที่จะไม่รั่วไหลรายละเอียดการใช้งานบางอย่างไปยังผู้ใช้ และ abstractions รั่วไม่ดี

หวังว่าจะช่วยได้มากขึ้นเพียงแค่ขอคำอธิบายในความคิดเห็นอย่างอื่น :)


+1 ฉันไม่เคยนึกเลยว่าโรงงานจะเป็นโซลูชั่นการออกแบบที่เป็นไปได้ สิ่งหนึ่งที่: มันฟังดูราวกับว่าโรงงานใช้สถานที่เดียวกันในอินเทอร์เฟซสาธารณะที่คอนสตรัคเตอร์รวม (+ บางทีอาจเป็นInitializeวิธีการ) จะครอบครอง นั่นทำให้ฉันสงสัยว่าโรงงานของคุณมีลักษณะอย่างไร
stakx

3

ในความคิดของฉันฉันคิดว่าคำตอบนั้นเหมือนคำแนะนำของคุณ # 2 สำหรับมวลรวมที่มีอยู่ สำหรับมวลรวมใหม่ ๆ เช่น # 3 (แต่กำลังประมวลผลเหตุการณ์ตามที่คุณแนะนำ)

นี่คือรหัสบางส่วนหวังว่ามันจะช่วยได้บ้าง

public abstract class Aggregate
{
    Dictionary<Type, Delegate> _handlers = new Dictionary<Type, Delegate>();

    protected Aggregate(long version = 0)
    {
        this.Version = version;
    }

    public long Version { get; private set; }

    protected void Handles<TEvent>(Action<TEvent> action)
        where TEvent : IDomainEvent            
    {
        this._handlers[typeof(TEvent)] = action;
    }

    private IList<IDomainEvent> _pendingEvents = new List<IDomainEvent>();

    // Apply a new event, and add to pending events to be committed to event store
    // when transaction completes
    protected void Apply(IDomainEvent @event)
    {
        this.Invoke(@event);
        this._pendingEvents.Add(@event);
    }

    // Invoke handler to change state of aggregate in response to event
    // Event may be an old event from the event store, or may be an event triggered
    // during the lifetime of this instance.
    protected void Invoke(IDomainEvent @event)
    {
        Delegate handler;
        if (this._handlers.TryGetValue(@event.GetType(), out handler))
            ((Action<TEvent>)handler)(@event);
    }
}

public class ShoppingCart : Aggregate
{
    private Guid _id, _customerId;

    private ShoppingCart(long version = 0)
        : base(version)
    {
         // Setup handlers for events
         Handles<ShoppingCartCreated>(OnShoppingCartCreated);
         // Handles<ItemAddedToShoppingCart>(OnItemAddedToShoppingCart);  
         // etc...
    } 

    public ShoppingCart(long version, IEnumerable<IDomainEvent> events)
        : this(version)
    {
         // Replay existing events to get current state
         foreach (var @event in events)
             this.Invoke(@event);
    }

    public ShoppingCart(Guid id, Guid customerId)
        : this()
    {
        // Process new event, changing state and storing event as pending event
        // to be saved when aggregate is committed.
        this.Apply(new ShoppingCartCreated(id, customerId));            
    }            

    private void OnShoppingCartCreated(ShoppingCartCreated @event)
    {
        this._id = @event.Id;
        this._customerId = @event.CustomerId;
    }
}

public class ShoppingCartCreated : IDomainEvent
{
    public ShoppingCartCreated(Guid id, Guid customerId)
    {
        this.Id = id;
        this.CustomerId = customerId;
    }

    public Guid Id { get; private set; }
    public Guid CustomerID { get; private set; }
}

0

เหตุการณ์แรกควรเป็นที่ลูกค้าสร้างตะกร้าสินค้าดังนั้นเมื่อสร้างตะกร้าสินค้าคุณมีรหัสลูกค้าเป็นส่วนหนึ่งของกิจกรรมแล้ว

หากสถานะเก็บไว้ระหว่างเหตุการณ์สองเหตุการณ์ที่แตกต่างกันโดยคำจำกัดความมันเป็นสถานะที่ถูกต้อง ดังนั้นหากคุณพูดว่าตะกร้าสินค้าที่ถูกต้องเชื่อมโยงกับลูกค้านั่นหมายความว่าคุณต้องมีข้อมูลลูกค้าเมื่อสร้างรถเข็นเอง


คำถามของฉันไม่เกี่ยวกับวิธีจำลองโดเมน ฉันจงใจใช้โมเดลโดเมนที่เรียบง่าย (แต่อาจไม่สมบูรณ์) เพื่อถามคำถาม ลองดูที่CreatedEmptyShoppingCartเหตุการณ์ในคำถามของฉัน: มีข้อมูลลูกค้าเช่นเดียวกับที่คุณแนะนำ คำถามของฉันคือเพิ่มเติมเกี่ยวกับเหตุการณ์ดังกล่าวเกี่ยวข้องหรือแข่งขันกับตัวสร้างคลาสของShoppingCartระหว่างการใช้งาน
stakx

1
ฉันเข้าใจคำถามที่ดีขึ้น ดังนั้นฉันจะบอกว่าคำตอบที่ถูกต้องควรเป็นคำตอบที่สาม ฉันคิดว่าเอนทิตีของคุณควรอยู่ในสถานะที่ถูกต้องเสมอ หากสิ่งนี้ต้องการให้รถเข็นสินค้ามีรหัสลูกค้าควรให้สิ่งนี้ในเวลาการสร้างดังนั้นความต้องการของตัวสร้างที่ยอมรับรหัสลูกค้า ผมคุ้นเคยมากขึ้นในการ CQRS ใน Scala ที่วัตถุโดเมนจะถูกแทนด้วยเรียนกรณีซึ่งจะไม่เปลี่ยนรูปและมีการก่อสร้างสำหรับการรวมเป็นไปได้ทั้งหมดของพารามิเตอร์ดังนั้นฉันได้ให้นี้สำหรับการรับ
อันเดรีย

0

หมายเหตุ: หากคุณไม่ต้องการใช้รูทรวม "เอนทิตี" ครอบคลุมสิ่งที่คุณกำลังถามเกี่ยวกับที่นี่ส่วนใหญ่ในขณะที่ด้านความกังวลเกี่ยวกับขอบเขตการทำธุรกรรม

นี่คือวิธีคิดอีกอย่างหนึ่ง: เอนทิตีคือเอกลักษณ์ + รัฐ เอนทิตีเป็นคนเดียวกันแม้ว่าสถานะของเขาจะเปลี่ยนไป

แต่รัฐเองก็คิดว่าเป็นวัตถุที่มีค่า โดยสิ่งนี้ฉันหมายถึงรัฐจะไม่เปลี่ยนรูป ประวัติของเอนทิตีคือการเปลี่ยนจากสถานะที่ไม่เปลี่ยนรูปแบบไปเป็นสถานะถัดไป - การเปลี่ยนแปลงแต่ละรายการสอดคล้องกับเหตุการณ์ในสตรีมเหตุการณ์

State nextState = currentState.onEvent(e);

วิธีการ onEvent () เป็นแบบสอบถามแน่นอน - currentState ไม่ได้เปลี่ยนเลยแทน currentState กำลังคำนวณอาร์กิวเมนต์ที่ใช้ในการสร้าง nextState

การติดตามโมเดลนี้รถเข็นช็อปปิ้งอินสแตนซ์ทั้งหมดสามารถถูกพิจารณาว่าเริ่มต้นจากค่าเมล็ดเดียวกัน ....

State currentState = ShoppingCart.SEED;
for (Event e : history) {
    currentState = currentState.onEvent(e);
}

ShoppingCart cart = new ShoppingCart(id, currentState);

การแยกข้อกังวล - ShoppingCart ประมวลผลคำสั่งเพื่อค้นหาว่าเหตุการณ์ใดควรเป็นเหตุการณ์ต่อไป ShoppingCart State รู้วิธีไปยังสถานะถัดไป

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.