part 4 까지의 작업내용은 https://github.com/shockzinfinity/CQRSCafe 를 참조하시기 바랍니다.
Read Models are about Queries
이전 단계에서 도메인 로직을 작성하는 방법을 살펴봤습니다. 커맨드가 도메인에 적용되면 아무 반응이 없을 것이고, 거절된다면 예외가 리턴이 될것입니다. 그렇다면 도메인의 현재 상태를 어떻게 알 수 있을까요? 그래서 필요한 것이 Read Models 입니다.
CQRS 에서는 이 부분을 Query 라고 애기하고, 이전 과정에서 애기하는 Query Facade는 바로 이 부분을 애기합니다. Read Models 는 Query 하는 것을 애기합니다. 도메인에서 발생된 이벤트로 인해 도메인의 상태가 변경이 되고, 클라이언트의 쿼리를 통해 그 결과를 확인할 수 있게 됩니다.
Read Models 에서는 아래와 같은 부분들을 대상으로 할 수 있습니다.
- An in-memory model of the data
- A relational model of the data, stored in an RDBMS
- Documents, stored in a document database
- A graph model of the data, sored in a graph database
- A search index
- An XML file, CSV file, Excel sheet, etc.
기존에 구축되어진 Repository가 어떤 부분인지에 따라 선택이 달라질 수 있으며, 심지어 event stream을 리턴할 수 있습니다. (여기서 중요한 것은 Repository 와 도메인과 분리해 낼 수 있다는 사실입니다.) 이 튜토리얼에서는 구현과정의 간단함을 위해 in-memory 방식의 Read Models를 택하겠습니다.
What read models will we need?
어떤 Read Models 이 필요하지는 사용자가 이 도메인에 어떤 정보가 필요한지에 따라서 다를 것입니다.
서빙을 하는 웨이터의 입장에서 보면 다음과 같은 정보들이 필요할 것입니다.
- The current list of open tabs
- The current tab for a particular table, with an indication of the status of each item
- The drinks that they need to serve
- Any food that is prepared and ready to be served to the table
주방장은 다음과 같은 정보가 필요할 겁니다.
- The current list of food that has been ordered and needs to be prepared
계산대에서는
- The tab for a table, and the total amount to be paid
쿼리(Read Models 에 대한)는 각 테이블 별의 정보를 리턴하는 단순 모델을 대상으로도 할 수 있지만, 각각의 구성원이 필요한 정보에 맞게 모델을 따로 생성하는 것이 더 효율적일 수 있습니다. 웨이터는 서빙해야 하는 목록에 대한 정보, 주방장은 준비해야 하는 음식에 대한 목록 등이 될 수 있습니다.
이 튜토리얼에서는 아래의 모델을 만들 것입니다.
- A chef todo list read model
- A wait staff todo list read model
- A tab read model
The Chef Todo List
이 모델은 두가지의 이벤트에 대해서 반응해야 합니다.
- FoodOrdered - Todo list 에 준비해야할 음식으로 등록
- FoodPrepared - Todo list 에서 삭제
이벤트에 반응할 것이므로, 해당 이벤트에 대한 옵저버로 등록을 하면 됩니다. 여기서는 ISubscribeTo 라는 인터페이스를 통해 구현 하겠습니다.
public interface ISubscribeTo<TEvent> { void Handle(TEvent e); }
public class ChefTodoList : ISubscribeTo<FoodOrdered>, ISubscribeTo<FoodPrepared> { // ... }
단순하게 생각하면 모든 주문을 prepare list 에 넣고 추가/삭제를 해도 되겠지만, 조금만 더 깊게 생각해보면 보통 손님들이 주문한 목록을 같이 준비하는 것이 더 효율적일 겁니다. 그래서 두개의 DTO(Data Transfer Object)를 만들어서 목록에 추가하는 형태로 가겠습니다.
public class TodoListItem { public int MenuNumber; public string Description; } public class TodoListGroup { public Guid Tab; public List<TodoListItem> Items; }
그리고 Read Model 에서 TodoListGroup 의 리스트를 반환하는 형태로 할 것입니다.
private List<TodoListGroup> _todoList = new List<TodoListGroup>(); public List<TodoListGroup> GetTodoList() { lock (_todoList) { return (from grp in _todoList select new TodoListGroup { Tab = grp.Tab, Items = new List<TodoListItem>(grp.Items) }).ToList(); } }
해당 목록에 대한 변경과 요청이 동시에 한 컬렉션에 접근하게 될 것이므로, thread safe 하게 만들어야 할 것입니다. thread safe 하게 만드는 이유 중의 또 한가지는 현재 in-memory 방식으로 컬렉션을 유지하기 때문이기도 합니다.
주문을 추가하는 커맨드 핸들러는 간단합니다.
public void Handle(FoodOrdered e) { var group = new TodoListGroup { Tab = e.Id, Items = new List<TodoListItem>( e.Items.Select(i => new TodoListItem { MenuNumber = i.MenuNumber, Description = i.Description })) }; lock (_todoList) { _todoList.Add(group); } }
foodprepared 핸들러는 현재 목록에 그룹핑 형태로 들어가 있을 것이므로, 그룹내의 준비 완료된 내용이 다 빠지면 그룹 자체도 제거가 되어야 합니다.
public void Handle(FoodPrepared e) { lock (_todoList) { var group = _todoList.First(g => g.Tab == e.Id); foreach (var num in e.MenuNumbers) { group.Items.Remove(group.Items.First(i => i.MenuNumber == num)); } if (group.Items.Count == 0) _todoList.Remove(group); } }
Two Become One
다음으로 구현할 Read Model 은 서빙하는 웨이터의 Todo 리스트 입니다. 이 리스트는 모든 이벤트에 대해서 핸들링 되면 될 것 같습니다.
public class ItemTodo { public int MenuNumber; public string Description; } public class TableTodo { public int TableNumber; public string Waiter; public List<ItemTodo> ToServe; public List<ItemTodo> InPreparation; } private Dictionary<Guid, TableTodo> todoByTab = new Dictionary<Guid,TableTodo>();
그리고, 서빙해야할 목록을 반환하는 메서드를 만듭니다.
public Dictionary<int, List<ItemTodo>> TodoListForWaiter(string waiter) { lock (todoByTab) return (from tab in todoByTab where tab.Value.Waiter == waiter select new { TableNumber = tab.Value.TableNumber, ToServe = CopyItems(tab.Value) }) .Where(t => t.ToServe.Count > 0) .ToDictionary(k => k.TableNumber, v => v.ToServe); }
웨이터의 todoList 는 해당 테이블의 모든 이벤트에 대한 기록과 거의 동일하므로, 현재 열려 있는 테이블에 대한 이벤트 목록이 될 수 있습니다.
Safety Through Interfaces
이전까지 인터페이스(ISubscribeTo)에 따른 구현으로 이벤트를 핸들링 했습니다. 그런데, 외부에서 직접 이 핸들링 메서드를 호출하는 것은 OOP적인 관점에서 볼 때 좋지 않으므로, 다른 인터페이스를 노출시켜서 해당 Read Model 에 접근하게 하는 것이 좋습니다.
public interface IChefTodoListQueries { List<ChefTodoList.TodoListGroup> GetTodoList(); } public interface IOpenTabQueries { List<int> ActiveTableNumbers(); OpenTabs.TabInvoice InvoiceForTable(int table); OpenTabs.TabStatus TabForTable(int table); Dictionary<int, List<OpenTabs.TabItem>> TodoListForWaiter(string waiter); }
나머지 부분에 대한 코드는 아래에 덧붙이겠습니다.
다음 파트에서는 여기까지 구현된 Read Model 을 바탕으로 Web frontend 를 구성하겠습니다.
ChefTodoList.cs
public class ChefTodoList : IChefTodoListQueries, ISubscribeTo<FoodOrdered>, ISubscribeTo<FoodPrepared> { private List<TodoListGroup> _todoList = new List<TodoListGroup>(); public List<TodoListGroup> GetTodoList() { lock (_todoList) { return (from grp in _todoList select new TodoListGroup { Tab = grp.Tab, Items = new List<TodoListItem>(grp.Items) }).ToList(); } } public void Handle(FoodPrepared e) { lock (_todoList) { var group = _todoList.First(g => g.Tab == e.Id); foreach (var num in e.MenuNumbers) { group.Items.Remove(group.Items.First(i => i.MenuNumber == num)); } if (group.Items.Count == 0) _todoList.Remove(group); } } public void Handle(FoodOrdered e) { var group = new TodoListGroup { Tab = e.Id, Items = new List<TodoListItem>( e.Items.Select(i => new TodoListItem { MenuNumber = i.MenuNumber, Description = i.Description })) }; lock (_todoList) { _todoList.Add(group); } } }
OpenTabs.cs
public class OpenTabs : IOpenTabQueries , ISubscribeTo<TabOpened> , ISubscribeTo<DrinksOrdered> , ISubscribeTo<FoodOrdered> , ISubscribeTo<FoodPrepared> , ISubscribeTo<DrinksServed> , ISubscribeTo<FoodServed> , ISubscribeTo<TabClosed> { private Dictionary<Guid, Tab> _todoByTab = new Dictionary<Guid, Tab>(); public List<int> ActiveTableNumbers() { lock (_todoByTab) { return (from tab in _todoByTab select tab.Value.TableNumber).OrderBy(i => i).ToList(); } } public TabInvoice InvoiceForTable(int table) { KeyValuePair<Guid, Tab> tab; lock (_todoByTab) { tab = _todoByTab.First(t => t.Value.TableNumber == table); } lock (tab.Value) { return new TabInvoice { TabId = tab.Key, TableNumber = tab.Value.TableNumber, Items = new List<TabItem>(tab.Value.Served), Totals = tab.Value.Served.Sum(i => i.Price), HasUnservedItems = tab.Value.InPreparation.Any() || tab.Value.ToServe.Any() }; } } public TabStatus TabForTable(int table) { lock (_todoByTab) { return (from tab in _todoByTab where tab.Value.TableNumber == table select new TabStatus { TabId = tab.Key, TableNumber = tab.Value.TableNumber, ToServe = CopyItems(tab.Value, t => t.ToServe), InPreparation = CopyItems(tab.Value, t => t.InPreparation), Served = CopyItems(tab.Value, t => t.Served) }).First(); } } public Guid TabIdForTable(int table) { lock (_todoByTab) { return (from tab in _todoByTab where tab.Value.TableNumber == table select tab.Key).First(); } } public Dictionary<int, List<TabItem>> TodoListForWaiter(string waiter) { lock (_todoByTab) { return (from tab in _todoByTab where tab.Value.Waiter == waiter select new { TableNumber = tab.Value.TableNumber, ToServe = CopyItems(tab.Value, t => t.ToServe) }) .Where(t => t.ToServe.Count > 0) .ToDictionary(k => k.TableNumber, v => v.ToServe); } } public void Handle(TabOpened e) { lock (_todoByTab) { _todoByTab.Add(e.Id, new Tab { TableNumber = e.TableNumber, Waiter = e.Waiter, ToServe = new List<TabItem>(), InPreparation = new List<TabItem>(), Served = new List<TabItem>() }); } } public void Handle(DrinksOrdered e) { AddItems(e.Id, e.Items.Select(d => new TabItem { MenuNumber = d.MenuNumber, Description = d.Description, Price = d.Price }), t => t.ToServe); } public void Handle(FoodOrdered e) { AddItems(e.Id, e.Items.Select(f => new TabItem { MenuNumber = f.MenuNumber, Description = f.Description, Price = f.Price }), t => t.InPreparation); } public void Handle(FoodPrepared e) { MoveItems(e.Id, e.MenuNumbers, t => t.InPreparation, t => t.ToServe); } public void Handle(DrinksServed e) { MoveItems(e.Id, e.MenuNumbers, t => t.ToServe, t => t.Served); } public void Handle(FoodServed e) { MoveItems(e.Id, e.MenuNumbers, t => t.ToServe, t => t.Served); } public void Handle(TabClosed e) { lock (_todoByTab) { _todoByTab.Remove(e.Id); } } #region helper methods private List<TabItem> CopyItems(Tab tableTodo, Func<Tab, List<TabItem>> selector) { lock (tableTodo) { return new List<TabItem>(selector(tableTodo)); } } private void AddItems(Guid tabId, IEnumerable<TabItem> newItems, Func<Tab, List<TabItem>> to) { var tab = GetTab(tabId); lock (tab) { to(tab).AddRange(newItems); } } private void MoveItems(Guid tabId, List<int> menuNumbers, Func<Tab, List<TabItem>> from, Func<Tab, List<TabItem>> to) { var tab = GetTab(tabId); lock (tab) { var fromList = from(tab); var toList = to(tab); foreach (var num in menuNumbers) { var serveItem = fromList.First(f => f.MenuNumber == num); fromList.Remove(serveItem); toList.Add(serveItem); } } } private Tab GetTab(Guid id) { lock (_todoByTab) { return _todoByTab[id]; } } #endregion }
Shared.cs
public class TodoListItem { public int MenuNumber; public string Description; } public class TodoListGroup { public Guid Tab; public List<TodoListItem> Items; } public class TabItem { public int MenuNumber; public string Description; public decimal Price; } public class TabStatus { public Guid TabId; public int TableNumber; public List<TabItem> ToServe; public List<TabItem> InPreparation; public List<TabItem> Served; } public class TabInvoice { public Guid TabId; public int TableNumber; public List<TabItem> Items; public decimal Totals; public bool HasUnservedItems; } public class Tab { public int TableNumber; public string Waiter; public List<TabItem> ToServe; public List<TabItem> InPreparation; public List<TabItem> Served; }
'Framework > CQRS' 카테고리의 다른 글
Event Sourcing (0) | 2015.12.15 |
---|---|
CQRS + Event Sourcing – A Step by Step Overview (0) | 2015.12.15 |
Cafe CQRS (Domain Logic) - part 3 (0) | 2015.12.01 |
Cafe CQRS - part 2 (0) | 2015.12.01 |
Cafe CQRS - part 1 (0) | 2015.12.01 |