我实际上正在用 C# 开发一个多线程游戏服务器。我正在尝试将其创建为可单元测试的。
到目前为止,我开始使用的模式类似于这样:
实例化Server类:
- 用于启动和关闭服务器
OnStart 服务器实例化 ConnectionManager :
- 包含 ConnectionPool 类的实例
- 用于接受异步连接(通过 TCPListener.BeginAcceptSocket)
- 当接受套接字时,将创建 ClientConnection 实例并将其添加到 ConnectionsPool 列表中
类ConnectionsPool:
- 负责向池中添加和删除事件连接,以列表形式进行管理
类ClientConnection:
- 负责通过Socket.BeginReceive接收数据
- 收到所有内容后,将创建 SocketHandler 实例(主要解析套接字内容并运行操作)
架构应如下所示:
服务器 -> ConnectionManager -> ConnectionsPool -> ClientConnection -> SocketHandler
问题:
当我在 SocketHandler 内部时,我如何影响其他玩家? (例如,玩家A击中玩家B:我需要在ConnectionsPool中获取玩家B的实例并更新他的HP属性)甚至更新服务器本身(比如调用Server类上的shutdown方法)
我假设有 3 个选择:
- 将所有重要的类(Server + ConnectionsPool)转换为 静态类 > 缺点:无法进行单元测试
- 将所有重要的类转换为 Singleton 类 > 缺点: 难以测试
- 在子构造函数中注入(inject)重要类的实例> 缺点:由于传递了大量信息,可读性较差
这里的目标是使用最佳实践使所有单元都可测试,同时保持简单的方法。
我收到了向 child 注入(inject)委托(delegate)的建议,这与第三种方法类似,但我不确定是否对其进行单元测试以及真正的效果,因为当然一切都是多线程的。
这里最好的架构选择是什么?
最佳答案
我发现的主题是网络代码并不是真正可进行单元测试的,并且应该与您希望测试的代码(例如 Player 对象)隔离。对我来说,您似乎将 Player 与 ClientConnection 紧密耦合,这可能会使其难以测试。我还认为将玩家与他的连接耦合可能违反了 SRP,因为他们有非常不同的职责。
我已经编写了一个看起来与您希望实现的系统类似的系统,并且我通过仅通过委托(delegate)和接口(interface)将玩家链接到连接来实现这一点。我有一个单独的 World 和 Server 类库,以及第三个项目 Application,它引用这两个项目,并创建 Server 和 World 的实例。我的 Player 对象位于世界中,连接位于服务器中 - 这两个项目不会相互引用,因此您可以考虑 Player,而无需任何网络条款。
我使用的方法是将SocketHandler抽象化,并在Application项目中实现一个具体的处理程序。处理程序具有对世界对象的引用,可以在创建时将其传递给它。 (我通过工厂类来实现这一点 - 例如,ClientConnectionFactory(在应用程序中),其中服务器只知道抽象连接和工厂。)
public class ClientConnectionFactory : IConnectionFactory
{
private readonly World world;
public ClientConnectionFactory(World world) {
this.world = world;
}
Connection IConnectionFactory.Create(Socket socket) {
return new ClientConnection(socket, world);
}
}
然后,ClientConnection 可以将 World 引用转发给它的处理程序,以及处理程序所需的有关 Connection 本身的任何信息,例如特别是 Send 方法。这里我只是将 Connection 对象本身传递给 Handler。
public ClientConnection(Socket socket, World world)
: base(socket) {
this.handler = new ClientHandler(this, world);
...
}
游戏逻辑和网络之间的大部分协调都包含在 ClientHandler 中,也许还有它引用的任何其他姐妹对象。
class ClientHandler : SocketHandler
{
private readonly Connection connection;
private readonly World world;
public ClientHandler(Connection connection, World world) {
this.connection = connection;
this.world = world;
...
}
//overriden method from SocketHandler
public override void HandleMessage(Byte[] data) {
...
}
}
剩下的部分涉及 ClientHandler 创建它的 Player 对象,分配委托(delegate)或接口(interface)来更新操作,然后将玩家添加到 World 中的玩家池中。最初,我使用 Player 中的事件列表来完成此操作,我使用 ClientHandler 中的方法(它知道 Connection)来订阅这些事件,但事实证明 Player 对象中有数十个事件,这使得维护变得一团糟。
我选择的选项是在播放器的 World 项目中使用抽象通知程序。例如,对于运动,我将在 Player 中拥有一个 IMovementNotifier 对象。在应用程序中,我将创建一个 ClientNotifier,它实现此接口(interface)并向客户端发送相关数据。 ClientHandler 将创建 ClientNotifier 并将其传递给它的 Player 对象。
class ClientNotifier : IMovementNotifier //, ..., + bunch of other notifiers
{
private readonly Connection connection;
public ClientHandler(Connection connection) {
this.connection = connection;
}
void IMovementNotifier.Move(Player player, Location destination) {
...
connection.Send(new MoveMessage(...));
}
}
可以更改 ClientHandler 构造函数来实例化此通知程序。
public ClientHandler(Connection connection, World world) {
this.connection = connection;
this.world = world;
...
var notifier = new ClientNotifier(this);
this.player = new Player(notifier);
this.world.Players.Add(player);
}
因此,最终的系统是 ClientHandler 负责所有传入消息和事件,而 ClientNotifier 处理所有传出内容。这两个类很难测试,因为它们包含很多其他垃圾。网络无论如何都无法真正进行单元测试,但是这两个类中的 Connection 对象可以被模拟。 World 库完全可以进行单元测试,无需考虑网络组件,这正是我在设计中想要实现的目标。
这是一个很大的系统,我在这里没有介绍太多,但希望这能给你一些提示。如果您想了解更多具体信息,请询问我。
如果我能提供更多建议,那就是避免任何静态或单例 - 他们会回来咬你。如果您需要作为替代方案,请支持增加复杂性,但要在需要的地方进行记录。我的系统对于维护人员来说可能看起来很复杂,但有详细的文档记录。对于用户来说,它使用起来特别简单。主要本质上是
var world = new World();
var connectionFactory = new ClientConnectionFactory(world);
var server = new Server(settings.LocalIP, settings.LocalPort, connectionFactory);
关于c# - child 对象与 parent 的沟通模式,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/7016212/