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 |