我有一个关于这个问题的后续问题。在面向对象的语言中,与多态类级常量等效的惯用Haskell是什么?
我正在尝试使用事件存储和Haskell进行事件源。我一直在努力找出保存和加载事件的逻辑。
事件存储基于事件流的概念;在面向对象的领域模型中,事件流和聚合之间通常存在1:1的关系。您可以将流组织为类别;通常,在域模型中,每个聚合类都有一个类别。以下是如何在C#中对其进行建模的示意图:
interface IEventStream<T> where T : Event
{
string Category { get; }
string StreamName { get; }
IEnumerable<T> Events { get; }
}
class PlayerEventStream : IEventStream<PlayerEvent>
{
public string Category { get { return "Player"; } }
public string StreamName { get; private set; }
public IEnumerable<PlayerEvent> Events { get; private set; }
public PlayerEventStream(int aggregateId)
{
StreamName = Category + "-" + aggregateId;
}
}
class GameEventStream : IEventStream<GameEvent>
{
public string Category { get { return "Game"; } }
public string StreamName { get; private set; }
public IEnumerable<GameEvent> Events { get; private set; }
public GameEventStream(int aggregateId)
{
StreamName = Category + "-" + aggregateId;
}
}
class EventStreamSaver
{
public void Save(IEventStream stream)
{
CreateStream(stream.StreamName);
AddToCategory(stream.StreamName, stream.Category);
SaveEvents(stream.StreamName, stream.Events);
}
}
此代码确保GameEvent
永远不会被发送到播放器的事件流,反之亦然,并且确保所有类别都被正确分配。我为Category
使用多态常量来帮助保护这个不变量,并使以后添加新的流类型变得容易。
这是我第一次尝试将这个结构翻译成Haskell:
data EventStream e = EventStream AggregateID [e]
streamName :: Event e => EventStream e -> String
streamName (EventStream aggregateID (e:events)) = (eventCategory e) ++ '-':(toString aggregateID)
class Event e where
eventCategory :: e -> String
-- and some other functions related to serialisation
instance Event PlayerEvent where
eventCategory _ = "Player"
instance Event GameEvent where
eventCategory _ = "Game"
saveEventStream :: Event e => EventStream e -> IO ()
saveEventStream stream@(EventStream id events) =
let name = streamName stream
category = eventCategory $ head events
in do
createStream name
addToCategory name category
saveEvents name events
这太难看了。类型系统要求eventCategory
在其签名中的某个位置提及e
,即使它在函数中的任何位置都没有使用。如果流中不包含事件(因为我试图将类别附加到事件类型),它也会失败。
我知道我正在尝试用Haskell编写C#——有没有更好的方法来实现这种类型的多态常量?
更新:根据请求,以下是我认为do
块中(目前未实现)存根应该具有的类型签名:
type StreamName = String
type CategoryName = String
createStream :: StreamName -> IO ()
addToCategory :: StreamName -> CategoryName -> IO ()
saveEvents :: Event e => StreamName -> [e] -> IO ()
这些函数将负责与数据库通信——设置模式并串行化事件。
有些人建议存在类型,但除非我有严重误解,否则您希望将某些偶数流限制为某些类型。
首先,
data EventStream e = EventStream AggregateID [e]
streamName :: Event e => EventStream e -> String
streamName (EventStream aggregateID (e:events)) = (eventCategory e) ++ '-':(toString aggregateID)
应该看起来很奇怪。您在第一个偶数调用eventCategory
,然后丢弃其余的,因此您假设所有事件的类别都是相同的。当然,eventCategory
可以为事件的不同值返回不同的字符串。如果没有事件,则必须执行eventCategory undefined
。
一个想法是改变eventCategory
:的类型
data Proxy p = Proxy
class Event e where
eventCategory :: Proxy e -> String
现在,函数不可能为事件的不同值返回不同的字符串,因为它无法访问实际值。换句话说,eventCategory
只取决于类型,而不是值。
另一种可能性是遵循c#代码,即类别是流的属性,而不是事件:
{-# LANGUAGE MultiParamTypeClasses #-}
import Data.ByteString
class Event e where
deserialize :: ByteString -> e
... other stuff
class Event e => EventStream t e where
category :: t e -> String
aggregateId :: t e -> Int
events :: t e -> [e]
name :: t e -> String
name s = category s ++ "-" ++ show (aggregateId s)
EventStream
类型类与接口紧密对应。
注意name
是如何在typeclass中的,但是您可以在不知道使用哪个实例的情况下编写它。您可以很容易地将其从类型类中移出,但实现可能会决定它将定义一个自定义名称,该名称将覆盖默认定义。
然后定义您的事件:
data PlayerEvent = ...
instance Event PlayerEvent where ...
data GameEvent = ...
instance Event GameEvent where ...
现在流类型:
data PlayerEventStream e = PES Int [e]
instance EventStream PlayerEventStream PlayerEvent where
category = const "Player"
aggregateId (PES n _) = n
events (PES _ e) = e
data GameEventStream e = GES Int [e]
instance EventStream GameEventStream GameEvent where
category = const "Game"
aggregateId (GES n _) = n
events (GES _ e) = e
请注意,事件类型是同构的,但仍然是不同的类型。GameEventStream
中不能有PlayerEvent
s(或者更确切地说,可以,但包含PlayerEvent
s的GameEventStream
没有EventStream
实例)。你甚至可以加强这种关系:
class Event e => EventStream t e | t -> e where
这意味着对于给定的流类型,可能只存在一个事件类型,因此定义两个这样的实例是一个类型错误:
instance EventStream PlayerEventStream PlayerEvent where
instance EventStream PlayerEventStream GameEvent where
保存功能微不足道:
saveEventStream :: EventStream t e => t e -> IO ()
saveEventStream s = do
createStream (name s)
addToCategory (name s) (category s)
saveEvents (name s) (events s)
我不知道这是否是你真正想要的,但它似乎完成了与c代码相同的事情。
我想向您展示如何在不使用类型类的情况下实现这一点。我经常发现结果更简单。
首先,这里有一些关于您可能使用的类型的猜测。您提供了其中一些:
type CategoryName = String
type Name = String
type PlayerID = Int
type StreamName = String
data Move = Left | Right | Wait
data PlayerEvent = PlayerCreated Name | NameUpdated Name
data GameEvent = GameStarted PlayerID PlayerID | MoveMade PlayerID Move
记录类型通常是类的有用替代品。在该示例中,CCD_ 18将是CCD_ 19或CCD_。
data EventStream e = EventStream
{ category :: String
, name :: String
, events :: [e]
}
在C#中,您有覆盖Category
属性的子类。在Haskell中,您可以为此目的使用智能构造函数。如果您的C#子类是重写方法,那么在Haskell中,您将在EventStream e
类型中包含函数。通过只公开以下智能构造函数,可以防止程序中的其他模块创建无效的EventStream
对象:
-- generalized smart constructor
mkEventStream :: CategoryName -> Int -> EventStream e
mkEventStream cat ident = EventStream cat (cat ++ " - " ++ show ident) []
playerEventStream :: Int -> EventStream PlayerEvent
playerEventStream = mkEventStream "Player"
gameEventStream :: Int -> EventStream GameEvent
gameEventStream = mkEventStream "Game"
最后,您定义了一些函数。以下是它们的写法:
createStream :: StreamName -> IO ()
createStream = undefined
addToCategory :: StreamName -> CategoryName -> IO ()
addToCategory = undefined
saveEvents :: EventStream e -> IO ()
saveEvents = undefined
saveEventStream :: EventStream e -> IO ()
saveEventStream stream = do
createStream name'
addToCategory name' (category stream)
saveEvents stream
where
name' = name stream
这更接近于您的C#示例所做的操作。流的名称在创建时是固定的,类别是每个流的一部分,而不是链接到每个元素的类型。