大型项目通常都采用了模块化的架构设计,这样就需要设计一些机制来实现模块间的通信和交互,下面会从最简单的实现开始讲起,然后依次介绍两类通信方式的实现思路和区别。

最简单的设计

最简单的设计就是不设计,直接针对该模块实现一个管理类xxxManager,该类对外公开的函数即可直接作为模块的通信接口。下面以音频模块的播放接口为例,展示一个简单的C#实现(后续示例代码均以C#为例):

public static class AudioManager
{
public static void Play(string audioPath)
{
// play audioPath...
}
}

调用模块的功能即直接调用静态方法:

AudioManager.Play("ExampleAudio");

上述这种方式最为直接,没有任何额外的设计,模块各自独立负责各自接口的通信,不经过框架层转发或管理。

事件(Event)系统

接下来介绍的是最为广泛使用的事件系统,顾名思义就是通过传递事件Event(如果携带了数据则叫作消息Message)的方式进行模块间的通信,这种方式可以有效的解耦,降低模块之间的依赖性,下面是事件系统的代码示例(为了方便理解仅列出函数接口,具体实现细节都大同小异,感兴趣的话可以参考文末的包含所有代码的示例工程链接)。

public interface IMessage {}

public class EventBus
{
private static Dictionary<string, List<Action<IMessage>>> listeners;

public static void AddListener<TMessage>(Action<TMessage> listener) where TMessage : IMessage {}
public static void RemoveListener<TMessage>(Action<TMessage> listener) where TMessage : IMessage {}
public static void RemoveAllListeners<TMessage>() where TMessage : IMessage {}
public static void Dispatch<TMessage>(TMessage message) where TMessage : IMessage {}

public static void AddListener(string eventName, Action<object> listener) {}
public static void RemoveListener(string eventName, Action<object> listener) {}
public static void RemoveAllListeners(string eventName){}
public static void Dispatch(string eventName, object message) {}
}

总的来说事件系统的运作方式是先由业务模块通过AddListener监听感兴趣的事件(也可以通过RemoveListener取消监听),EventBus内部维护了一个listeners哈希表来保存所有的监听映射关系,然后调用方通过Dispatch派发相应事件来完成模块间的通信。我们约定事件的名称即为所定义Message的类型名称,在传递泛型类型参数TMessage时即可获取。当然EventBus除了提供一组泛型函数族方便C#调用,考虑到混合编程和其它语言交互的兼容性,EventBus还提供了一组非泛型函数族,可以通过其它语言(主要是脚本语言)进行调用。下面是一段简单的示例代码实现了事件的定义和处理,同样是以音频模块的播放接口为例:

public class PlayAudioMessage : IMessage
{
public string AudioPath {get; private set;};
public PlayAudioMessage(string audioPath)
{
this.AudioPath = audioPath;
}
}
public class AudioModule
{
public void Load()
{
EventBus.AddListener<PlayAudioMessage>(OnPlayAudioMessage);
}
public void Unload()
{
EventBus.RemoveListener<PlayAudioMessage>(OnPlayAudioMessage);
}
private void OnPlayAudioMessage(PlayAudioMessage msg)
{
// play msg.AudioPath...
}
}

通过派发事件来调用模块的功能:

EventBus.Dispatch(new PlayAudioMessage("ExampleAudio"));

从设计目标上来看,事件更适合一对多的通信方式,即发送者预期会有0至多个接收者,这种通信方式主导方是发送者,这意味着对事件的定义要由发送者负责,但是发送者并不介意没有接收者响应事件,也不期望从事件触发中获取返回值。由此可见事件本身其实不太适合做模块之间的功能调用,因为模块的功能调用大多数其实属于一对一的通信,即每次调用预期有且只有一个接收者,并且常常需要获取返回值(一对多的方式无法获取返回值)。例如在上述例子中,尽管我们派发了播放音频的事件,但是我们并不需要有多个接收者,只需要音频模块接收并响应即可,当我们想要获取播放音频是否成功的结果,事件就无能为力了,此时就轮到命令系统登场了。

命令(Command)系统

由此我们可以设计一套命令系统来解决上述一对一通信方式(借用设计模式命令模式的思想):我们将命令Command定义为一类特殊的事件,让每条命令必须有且只有一个接收方,和接收方强绑定,也就是这种通信方式主导方是接收方,命令的定义也由接收方负责,由于只有一个接收方,所以命令的响应是可以有返回值的。从这个角度来说,事件更像是模块对外部的被动响应和回调,派发事件这个行为是对一个已经产生了的结果进行广播和通知;命令更适合定义模块主动对外暴露的接口,调用命令这个行为才是去主动请求相应模块执行一些行为并获取结果。很多项目常常会混淆两者,是因为没有明确区分这两种行为,导致定义了一些看起来很别扭的”事件”。下面是命令系统的代码示例:

public interface ICommand {
bool IsAsync{get;}
bool MultiThreadSupported{get;}
}
public class CommandBus {

private static Dictionary<string, Action<ICommand>> callHandlers;
private static Dictionary<string, Func<ICommand, object>> postHandlers;

public static void Handle<TCommand>(Action<TCommand> handler) where TCommand : ICommand {}
public static void Handle<TCommand, TResult>(Func<TCommand, TResult> handler) where TCommand : ICommand {}
public static void Unhandle<TCommand>() where TCommand : ICommand {}
public static void Call<TCommand>(TCommand cmd) where TCommand : ICommand {}
public static TResult Post<TCommand, TResult>(TCommand cmd) where TCommand : ICommand {}

public static void Handle(string cmdName, Action<object> handler){}
public static void Handle(string cmdName, Func<object, object> handler){}
public static void Unhandle(string cmdName){}
public static void Call(string cmdName, object cmd) {}
public static object Post(string cmdName, object cmd) {}
}

命令系统的实现和事件系统大体相似,但是仍然有一些不同:由于命令调用支持返回结果,所以CommandBus会根据命令调用是否有返回值将其区分为CallPost两种函数,以此来替代事件系统中的Dispatch,CommandBus内部也同样要维护两个handlers来保存映射关系;因为每条命令有且只有一个接收方,所以不再使用AddListener函数添加监听者,而是使用Handle函数直接进行一对一绑定,甚至我们可以直接去掉手动绑定的步骤,在定义Command时就绑定接收方的类型(因为命令本来就由接收方负责定义),在调用命令时通过反射执行对应模块的响应函数;Command自身也可以抽象出一些影响调用流程的通用属性,例如是否支持多线程、是否为异步调用、依赖哪些资源等等,可以让模块间的通信在框架层有更统一、更清晰的描述。下面代码仍然是音频播放模块的功能调用,改成使用命令系统实现:

public class PlayAudioCommand : ICommand
{
public string AudioPath {get; private set;};
public PlayAudioCommand(string audioPath)
{
this.AudioPath = audioPath;
}
}
public class AudioModule
{
public void Load()
{
CommandBus.Handle<PlayAudioCommand>(OnPlayAudioCommand);
}
public void Unload()
{
CommandBus.Unhandle<PlayAudioCommand>();
}
public void OnPlayAudioCommand(PlayAudioCommand cmd)
{
// play cmd.AudioPath...
}
}

和派发事件类似,通过调用命令来调用模块的功能:

CommandBus.Call(new PlayAudioCommand("ExampleAudio"));

在框架设计上很多人会有一个误区:喜欢过度使用语言的高级特性,在框架层面这很容易导致简单问题复杂化。以C#为例,我见过很多框架底层Attribute、Reflection满天飞,看似潇洒自如,实则漏洞百出,诚然这些语言层面的高级动态特性配合runtime使用起来很爽,但代价是失去了对细节的掌控力,它带来的是方便性和易用性、而不是灵活性或扩展性,并且如果你的框架实现底层依赖这些高级特性,反而失去了灵活性,最灵活的永远是函数。当然并不是说我们不能在框架层去使用高级特性,这是一个取舍问题,使用它们一定要建立在已经对这些高级特性有着深刻理解的基础上,只有将它们应用在最合适的地方才能发挥其最大的优势,而不是说能用就一定要用、杀鸡用牛刀。正所谓重剑无锋,大巧不工,就像C++的确在C的基础上扩展了很多语法和功能,但是最灵活的用法仍然是C就支持的函数调用和函数指针。我们需要的是一个可靠的、可调试的、可扩展的灵活架构,而不是一个花里胡哨看起来高大上的杂耍玩具。

总结

用事件或命令将模块间的通信调用和普通的函数调用区分开的这种设计是很有意义的,当将模块间的通信行为统一抽象为事件派发或命令调用后,无论是使用事件还是使用命令,都要比直接的函数调用更加直观,因为往往模块间通信是比普通函数调用更重要、更值得关注的一个行为,将该行为统一经过commandmessage封装并中转,可以更方便的进行日志记录、profile耗时统计等操作。建议在项目中的实践是事件和命令共同使用,明确模块间的功能调用是属于哪种类型的通信,然后再选择使用事件或命令,下面是两种方式的比较和总结:

通信方式 事件 命令
消息定义方 发送方 接收方
接收方数量 0~N 1
可以返回值 x
可以异步 x

完整代码示例

EventBus实现可参考https://gist.github.com/handsomesnail/07af9278681b6e7a9f691a9365fc1392
CommandBus实现可参考https://gist.github.com/handsomesnail/f8cab157c443afadee59a1d2af52ec48