본문 바로가기

Framework/CQRS

Cafe CQRS (Read Models) - part 4

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