先来为没有听说过Minetest(现已更名为Luanti)的读者简单介绍一下Minetest是一款类似于我的世界类型的沙盒类游戏的游戏引擎100%开源是初学者学习游戏引擎设计与实现的最佳实践读者如果想要下载源代码的话可以在Linux(Ubuntu)的终端输入如下命令:git clone https://github.com/minetest/minetest.git更详细的介绍可以去问AI或是查看官方文档这里不多BB直接开始上干货。1.大框架搭建Minetest整个项目有1400多个文件约40多万行代码主要使用了CC和Lua脚本语言如果读者只了解C/C不清楚Lua也不用慌如果你的C/C掌握的牢固Lua是可以直接看懂并很快上手的整个项目大体上分为三层分别是: 游戏层接口层和引擎核心层我们要看的重点就位于引擎核心层该层主要使用C实现可以分为五个模块分别是:渲染模块, 地图生成模块, 网络同步模块, 脚本绑定模块与物理逻辑模块。对于这种大型项目肯定是不能从头看到尾的必须要透过代码看出其中的逻辑要说明的是笔者也是第一次分析这种大型项目如果有哪些地方分析的有问题的话还请指出。下面进入正题。1.1网络同步模块在分析前首先要明确要按什么顺序进行分析依据笔者不多的分析源代码的经历来看大体上可以分为按执行流程分析和按作用模块分析按流程分析就是从main函数开始按照程序执行的流程一点点分析不过笔者目前最熟悉的知识体系就只有网络同步模块其它4个模块是没什么理解的因此选择按照模块分析从自己已有的知识开始一点点往后推理所以分析的第一个模块就是网络同步模块先不看源代码我们自己猜想一下网络同步模块要干些什么根据笔者的编程经验首先肯定要有一个客户端与一个服务端两端使用网络协议进行通信一般来说在游戏这种实时性要求高的情况下会采用UDP协议进行通信因为UDP协议虽然不保证可靠性但是它快速轻量非常适合实时在线的游戏通信并且通过上层的封装也是可以让UDP协议变得可靠的那么最简单的网络同步模块的模型就是这样子的:把所有游戏模块都视为黑盒上图就是一款游戏的本质了任何游戏都是我们现在要做的就是根据Minetest源代码中的网络同步模块部分一点点的完善上图现在来补充细节首先在两端都需要有报文接收与发送功能的模块并且还需要管理所有的链接把收发报文与管理链接两个功能组合起来称为链接管理层在获取到了UDP报文后肯定需要对报文进行解析并且在发送报文时也需要对报文进行封装也就是序列化与反序列化我们将负责该功能的层称为协议层协议层在解析了报文后必然要交付给应用层处理我们称该层为业务逻辑层依据我们的猜想把网络同步模块再次划分成了链接管理层协议层与业务逻辑层下面正式开始分析源代码先来看看链接管理层在Minetest中是如何实现的。1.1.1链接管理层首先链接管理层的主要文件是:在这部分文件中又可以划分为实现辅助功能的小组件和实现主功能的类我们先从实现主功能的类入手理清楚一条主脉络先看到connextion.h文件中的一个类:很显然该类是一个抽象基类规定了接口的形式并且从名称也可以看出来这个类与链接的关系非常大下面再看到impl.h文件中的另一个类:显然Connection就是继承了IConnection的具体实现了final关键字指明Connection不可被继承之后就以IConnection与Connection类为核心来分析链接管理层的第一条脉络先看到IConnect中的函数名称大概的猜测一下Serve应该是让服务端开启链接并绑定IP地址和端口号的Connect应该是让客户端链接上服务端的Connected应该是让客户端检查链接是否建立成功的Disconnect与DisconnectPeer应该与断开连接有关ReceiveTimeoutMs应该是用于接收UDP报文的并且还设置了超时时间而TryReceive显然就是超时时间为0的完全非阻塞式的报文获取接口了Send显然与UDP报文的发送有关get系列的接口应该是用于确定连接信息的。下面让我们去Connection类中确认一下首先成员变量有这些:成员函数的大部分操作一定都是对成员变量进行的因此我们先来大概猜测一下成员变量的作用从名称与类型来看m_event_queue应该是一条阻塞队列在队列中存放的应该是连接事件的指针m_peer_id应该是连接的唯一标识在完整的连接中一个服务端要对接多个客户端每一个服务端与客户端都会有自己的Connection对象因此可以使用m_peer_id来唯一标识一次完整连接中的单个服务端或客户端m_protocol_id应该是与协议有关的id估计是用于版本匹配的比如每次客户端向服务端发送报文时就先匹配一次服务端的m_protocol_id与客户端的m_protocol_id如果客户端的版本不匹配的话那么服务端就断开连接或是让客户端更新协议版本m_peers显然是配合m_peer_id使用的其结构是一个红黑树结构特征是能够快速的提供key定位到对应的value在此处应该就是通过m_peer_id唯一的定位到一个Peer对象的指针尽管我们还没有查看Peer具体的什么但是可以大胆的猜测一下首先m_peer_id是用于唯一标识一个链接的但是能够唯一标识链接显然是不够的应该还得要通过m_peer_id快速的定位到一个具体的链接比如同时有三个客户端向服务端发送了报文那么服务端在处理完成后就应该要能够快速准确的进行返回首先就得找到具体的客户端因此就可以通过m_peer_id在自身的m_peers中查找出一个Peer指针显然Peer类型中存储的应该是一个客户端的信息比如IP地址和端口号信息m_peer_ids中显然就是存储所有m_peer_id的了在服务端向所有客户端广播某条信息时就可以遍历m_peer_ids找到所有的客户端并发送信息游戏中的全服广播就是类似的原理m_peers_mutex显然就是一个锁了用于保证线程安全的从阻塞队列中拿取或是放入报文m_sendThread应该是用于发送报文报文的线程而m_recviveThread应该是用于接收报文的线程m_info_mutex是另一把锁目前还无法判断出具体的作用下面的m_bc_peerhandler是一个指向PeerHandler类型的指针PeerHandler应该是一个用于存放回调函数的类最后的m_shutting_down从名称来看应该和关闭有关估计是用来标识链接状态的比如链接还未断开就标记为false链接断开了就标记shutt_down为true在下面的m_udpSocket是一个自定义UDP套接字类型该类具体的定义位于socket.cpp文件中很显然m_udpSocket的作用就是绑定IP地址与端口号了并建立套接字对象了实际的发送与接收调用的底层接口也多半是在该类型中的最后一个m_command_queue也是一条阻塞队列不过在队列中存储的内容不同从名称来看存储从应该是发送指令的指针单从发送与接收对称的角度来看这两条阻塞队列应该一条用于存放接收信息另一条用来存放发送信息的那么具体的工作流程就是客户端把存放在发送阻塞队列中的UDP报文发送给服务端服务端在收到报文后就把报文存入接收阻塞队列然后业务层再把接收阻塞队列中的报文取出在处理完毕后再放入服务端的发送阻塞队列然后再发送出去客户端在接收到应答报文后就把报文存入接收阻塞队列。根据上方成员变量字段的分析可以大概推测出Minetest的链接管理层是使用了:多线程阻塞队列生产者消费者模型进行信息收发的简易建模就是这样子:在上方的分析中也可以看出一个好的名称有多么重要了笔者能够分析出来这些信息主要是因为笔者之前手写过类似的多线程阻塞队列生产者消费者模型的服务器只不过用的是Epoll但是原理是类似的并且笔者在项目中的命名也是类似的风格因此单单只是从成员变量的名称就可以推理出大量的信息和主体结构了当然肯定还存在不少细节问题毕竟上方仅仅只是根据成员变量得出来的信息下面就来看看Connection具体函数的实现以此来验证我们的猜测先来看看构造函数对于成员变量是如何初始化的:首先是m_udpSocket传入了一个bool类型可以判断Minetest应该是同时支持IPv4与IPv6并且可以切换的m_protocol_id传入了一个宏其值就是当前服务端与客户端的版本然后是给m_sendThread和m_receivedThread分别申请了不同的线程对象其中在初始化m_sendThread时传入了两个参数从名称来看应该是指定了一次发送报文的最大发送数和发送等待的超时时间然后是m_bc_peerhandler按照上方对于成员变量的分析我们猜测应该是注册了一个回调函数类在函数体中显然就是对线程和套接字的一些初始值设置操作并启动了发送线程与接收线程。目前分析到了岔路口我们有两种往下分析的路径其一是直接去看Connection中的函数摸清楚各个函数模块的作用其二是顺着这个构造函数中使用到的函数按照执行流顺序往后推理看过笔者其它文章的读者应该知道笔者喜欢把知识链串起来因此在此处选择第二个分析思路以该构造函数为起点继续往后分析首先就是m_udeSocket是时候去看看socker.h了:从该类的声明中可以发现有三个成员变量其中m_handle无法确定作用但是m_timeout_ms肯定和时间设置有关m_addr_family肯定与套接字的模式有关在成员函数中Send显然就是用来发送数据的而Receive显然就是接收数据的不过此时我们的目的并不是分析透UDPSocket类而是为了分析透Connection才来看UDPSocket类的因此只分析我们需要的因此看到UDPSocket的构造函数:从参数类型可以确定在Connection中初始化m_udpSocket使用的就是该构造函数了让我们继续往下看看init函数:参数是两个bool类型第一个显然是指定是否使用IPv6第二个应该与是否抛异常有关在函数的第一个if中看到判断值与g_sockets_initialized有关该值不是函数的成员变量那么就应该是一个全局的变量该操作是一个防御性编程简单来说就是在操作系统层面准备网络套接字也是需要时间的(主要是Windows平台需要)因此在调用系统调用前必须先保证操作系统准备好了g_sockets_initialized就是在操作系统准备好套接字创建环境后就被置为true否则就置为false避免在下面调用socket时调用失败导致程序崩溃然后是下方的if判断判断的值是m_handle现在就可以看出m_handle的效果就是接收socket的返回值了在Linux中就是接收套接字创建后操作系统分配出来的文件描述符后续的所有通信都要使用该文件描述符与操作系统交互那么在此处的if条件判断显然就是避免一个套接字被重复创建的情况了上方两条防御性if结束然后正式开始创建套接字可以看到在m_addr_family出印证了我们上方的猜想Minetest确实是同时支持IPv6与IPv4并且可以互相切换的在下面就是创建实际的套接字了让m_hendle获取文件描述符之后的if语句显然就是通过返回值判断套接字是否创建成功的(这里我们先不管异常模块和日志模块)最后又有一个函数看名称与超时时间的设置有关来具体看看:嗯非常简单没什么好说的然后回到Connection的初始化函数中我们已经明确了m_udpSocket的初始化流程然后是m_protocol_id这个没什么好说的就是使用一个值继续初始化版本的匹配的逻辑要在后面才会体现下面来看看初始化的重点也就是发送线程:m_sendThread与接收线程m_receiveThread的初始化先来看m_sendThread也就是类型ConnectionSendThread的构造函数与成员变量:成员变量:构造函数:先来看成员变量m_connection明显是一个回指向Connection的指针其效果应该是让线程能够调用自身所属Connection对象的函数m_max_packet_size应该是一次最大发送报文的上限报文大小m_timeout显然是超时时间比如在等待阻塞队列中的报文时如果等待时间超过了m_timeout那么就先停止等待让线程去处理其它工作(猜测)然后_m_outgoing_queue是一条队列可能是把从阻塞队列中的一批报文取出来后再放入该队列然后一个个发送给指定的客户端或服务端m_send_sleep_semaphore是一个自定义类型对象从变量名称来看应该与发送休眠有关m_iteration_packets_avaialble看名称估计与迭代器有关可能是迭代有效可发送报文的然后是m_max_data_packets_per_iteration好像又是一个迭代器相关的成员变量迭代的好像与报文的内容有关最后的m_max_packets_requeued估计是一次可以从阻塞队列中获取的最大报文的数量。读者可能发现了上文的大部分内容似乎都是靠猜的完全没有笔者之前文章的那种严谨性在这里要告诉读者的是分析语法操作系统计算机组成原理与分析实际项目完全是两码事在实际分析项目时如果你还要严谨那么你分析1年都不一定能分析完10万行代码进了公司后撑死了给你2周查看项目的时间然后就要上手修bug和接需求了那么就不可能严谨了在初步分析时大部分内容都是从自己熟悉的模块开始然后靠猜经验结合的笔者上方的流程就是按照这样子来的可能不够严谨但是其思想与方法论一定能够让你在进入公司后以最快的速度上手项目翻越实习生入职后最难翻过第一座大山(分析源代码)下面回到正题看到ConnectionSendThread的构造函数:首先是Thread的构造显然是显示调用了基类的构造函数可以推测出ConnectionSendThread应该是继承了Thread来看看是不是这样的:确实如此因此我们得先去看看Thread的成员变量与构造函数了:成员变量:构造函数:首先是成员变量显然m_name与一个线程的名称有关然后是m_retval是一个void*类型的通用指针目前还无法确定实际用途然后是m_joinable这个显然与线程的回收发生有关应该是指定让线程被主线程等待回收还是让操作系统自动回收的然后是m_request_stop与m_running估计是与线程的启动与暂停有关的下面还有两把锁m_mutex应该是用于原子的访问资源的m_start_finished_mutex应该是与启动与结束有关的最后一个m_thread_obj显然就是线程真正的主体了采用了C标准库中的线程然后来看看构造函数非常简单只初始化指定了线程的名称然后把启动标志和暂停标识都初始化成了false。下面回到ConnectionSendThread的构造函数基类对象Thread的初始化名称是 ConnectionSend那么我们有理由推断或许是使用m_name来区分发送线程与接收线程接收线程多半也会继承Thread并且其名称大概率会是ConnectionRecive之后初始化的m_max_packet_size和m_timeout显然就是指定指定发送报文的最大大小与超时时间了下一个是m_max_data_packets_per_iteration这个成员变量的初始化比较奇怪冒出来了一个get_settings首先要搞明白这东西在哪里既然其不是成员变量又可以直接在类中被使用那么多半就是全局的了笔者最终在setting.h中找到了该全局变量:这东西的类型是Settings估计与游戏的初始设置有关这个类比较复杂我们就只挑出其中的getU16函数查看:可以看到该函数接收一个字符串返回一个uint16_t类型return处的stoi显然是一个函数笔者在util中找到了该函数:其中s32是对int32_t从重命名显然该函数先将字符串转为s32然后限制了其大小在0到65535之间但是目前我们还无法推测出这么做的意义先回到ConnectionSendThread中在调用getU16时传入了一个宏该宏值是:是一个字符串..嗯..更奇怪了显然该字符串要通过get(name)来转换成存放整数的字符串下面来看看get函数:显然还得继续往下看getEntry函数并且还涉及到了SettingsEntry类,让我们继续深入:在getEntry函数中进来就直接加了锁使用的是RAII风格的可以判断该函数肯定是要访问临界资源的然后是定义了一个n看类型名称似乎是一个迭代器在之后又使用了m_settings这东西绝对是Settings类的一个成员变量去看看吧:显然推测正确不过m_settings本身又是SettingEntries类型的可以确定的是该类型中一定有一个find成员函数和一个end成员函数并且还有一个迭代器去看看吧:好家伙原来是个重命名那么显然m_settings.find(name)就是一次KV操作了通过字符串name找到一个SettingsEntry类型的对象显然得去看看SettingsEntry了一个非常简单的结构体重点要关注其中的value这个value绝对会被初始化成一个存放整数的字符串不过还无法确定具体的内容目前还无法找到往SettingEntries中插入元素的操作在哪里或许在其它模块暂且先放一放那么函数开始往回调首先在getEntry中返回的n-second就是一个SettingsEntry对象然后在get中使用entry接收了返回值在get函数中又返回了entry.value然后返回值就被stoi接收并传入了mystoi函数中最后在mystoi中返回了一个s32类型的整数到getU16中在getU16中又将该整数返回给了ConnectionSendThread的构造函数真是一条复杂的调用链我们还没有分析出这么做的意义不过肯定是存在某些作用的下面去看看ConnectionSendThread函数体中的内容:首先mppi引用了成员变量然后就对mppi进行了修改调用的显然是一个宏函数下面去看看:一条非常简单的宏函数其效果就是让mppi的值至少大于或等于1然后是mppi_default可以发现Settings是一个非常重要的类看调用函数的名字getLayer应该是与线有关的设置这显然不属于链接管理层的内容了我们线放一放至此ConnectionSendThread初始化完成把视角拉回Connection中:目前我们已经分析了前三个成员变量的初始化下面是m_receiveThread简单推测一下应该和m_sendThread的初始化是类似的让我们去看看:非常简单并且也与我们上方的猜测相同接收线程确实是叫ConnectionReceive的发送比接收复杂是必然的因为在接收时复杂的工作都被对方的发送线程处理好了。回到Connection中初始化列表中最后是m_bc_peerhandler的初始化根据之前的猜测其多半和回调函数有关去看看吧:是一个简单的抽象基类从函数名称来看肯定和peer的添加与删除有关先忽略具体的细节继续分析Connection函数体中的内容首先是setTimeoutMs明显是设置超时时间的函数细节如下:然后是两个setParent有点奇怪了我们现在已知发送线程和接收线程都是继承了Thread的但是在setParent中却传入了this去看看具体的函数实现:在上文我们猜测m_connection是让发送线程与接收线程回调Connection中的函数的目前来看猜测应该是正确的在Connection中的最后两个start显然就是启动线程了去看看吧:发送线程与接收线程的start调用的都是继承自Thread中的start在该函数中进来就加了个锁说明整个函数都属于临界区域然后是一个if判断根据m_running来采取不同的行为m_running在上文分析过了其默认值为false然后将m_request_stop设置成了false含义应该就是不需要暂停下面又有一把锁注释信息的含义是:“如果线程正在被重启该互斥锁mutex可能已经被锁定了。待办/疑问如果操作失败了怎么办或者如果它已经被同一个线程锁定了又该怎么办这把锁应该就是解决这个问题的然后在try中真正的申请了线程使用了C标准库中的线程传入了两个参数其中threadProc显然就是线程要执行的函数而this就是传入的参数也就是说这个threadProc必然是重点来看看吧:首先this就是传递给thr的在函数体中将current_thread设置成了thrcurrent_thread不是成员变量那么多半是全局的即:是一个Thread对象不过应该是加了锁目前还无法确定实际用途暂时忽略然后显然就是设置线程的名称并注册到日志中了先暂时忽略日志模块注意到最关键的run线程执行任务的循环一定就位于其中要注意的是Thread是一个抽象基类有非常多在子类都继承了它每一个继承Thread的类中必然都实现了run我们在此处重点要关注的是发送线程的run和接收线程的run先来看看发送线程的:意料之中的是一个非常复杂的函数我们先忽略上方那些前置的设置看到最重要的主循环:好吧主循环也非常复杂一点点分析吧首先是循环条件的判断使用了两个函数看名称应该是检测否需要暂停和检查阻塞队列中是否有报文的去看看吧:stopRequested是Thread中的函数而packetsQueued显然是ConnectionSendThread特有的首先是stopRequested函数直接返回了m_request_stop这印证了上文的猜想m_request_stop确实是用于停止线程的然后是packetsQueued函数首先是调用了getPeerIDs应该是获取所有链接的id的函数如下:返回了m_peer_ids该字段在上文就分析过了是Connection类的成员变量类型就是vectorsession_t的并且还发现是使用m_connection调用的那么猜测正确m_peer_ids确实是存储所有链接id的m_connection也确实是给线程调用链接对象中的函数的也就是每一个链接都对应一批线程每一个线程都有一个回指向链接的指针并通过该指针调用链接中的函数然后是两个判空含义应该就是如果发送阻塞队列中有报文并且此时还有链接那么就返回true下面是一个循环遍历peerIds在服务端的角度对应的应该就是遍历此时与服务端链接的所有客户端在遍历中又使用了m_connection调用线程对应链接中的函数并且又出现了一个新的PeerHelper类型去看看吧:PeerHelper是对Peer的封装Peer是一个非常复杂的类我们暂且先不关注看到getPeerNoEx函数显然是在m_peers中查找对应的Peer并交给迭代器node然后返回了使用查找到的Peer指针构造出来的一个PeerHelper回到packetsQueued后面是两个判断显然语义就是如果peer为空那么说明对应的peerId是无效的就继续遍历下一个如果peer无法为转换为UDPpeer那么也继续循环注意到被PeerHelper重载了因此不是取出了一个PeerHelper对象的地址而是返回了底层的Peer对象的指针又由于使用的是dynamic_cast是专门用于将基类类型转换为子类类型的因此可以推测UDPPeer必然是继承了Peer的去看看吧:结果如我们所料并且还指定了final表明final是不可被继承的因此上方判断的含义就是如果Peer无法被转换成UDPPeer那么就继续遍历下一个Peer下面又是一个遍历并且又出现了新的类型Channel看名称应该与管道有关系。从语法上来看应该是遍历每一条管道然后查看管道的阻塞队列中是否存在信息如果存在那么就返回trueChannel是一个非常复杂的类我们先只看我们需要的也就是Channel中的queued_commands:是一条队列队列中存放的元素是ConnectionCommandPtr类型该类型我们在上文大概猜测过下面去大概看看其中的主要成员变量:可以看到最终的类型是ConnectionCommand该类型一定与链接指令有关目前先不深挖其细节回到packetsQueued中可以确定该函数只有两种情况会返回true其一是发送队列不为空并且存在对应的链接其二是链接命令队列不为空再回到发送线程的主循环中:可以判断进入循环的条件确实就是线程没有暂停与阻塞队列中存在报文了下面进入循环体要说明的是这里的调用链非常复杂涉及到了大量的类我们必须抓住一个核心那就是无论封装的有多么复杂最终要把信息send出去就必须得调用系统调用sendto函数这才是最终的目的其它所有的工作都是为了辅助该工作而进行的因此先不管上层的封装让我们找到调用sendto的地方在哪里注意到循环体末尾的sendPackets函数显然该函数与send有关函数实现如下:该函数非常长笔者只截取了函数中的主循环同样的先找与send相关的函数显然就是rawSendAsPacket了去看看吧:该函数同样非常长笔者只截取了与send有关的部分可以发现还是没有sendto不过有两个名字带send的函数先来看看上方那个sendAsPacketReliable:可以发现sendAsPacketReliable最终也是调用了rawSend只不过封装了一下p我们有理由推断在rawSend函数就是真正调用sendto函数的地方去看看吧注意到线程使用了回执行Connection的指针访问到了其中的成员变量m_udpSocket该成员变量是保护的而线程类又没有继承链接类那么线程类应该就是链接类的友元确实如此而m_udpSocket在上文分析过了是UDPsocket类型的其中的Send函数如下好的找到sendto了报文最终就是从这里发送出去的发送报文前的预备工作必然是非常复杂的先不管那些细节回到Connection类中下方对于接收线程的启动必然也是类似的逻辑可以推断在UDPsocket类中应该存在一个Recv函数确实如此目前我们以及分析了大量的类是时候进行一次总结了笔者将把已经分析的部分绘制成一张类图类图代码如下startuml title 链接管理层类图 class IConnection{ {abstract} Serve(bind_addr : Address) void {abstract} Connect(address : Address) void {abstract} Send(peer_id : session_t, channelnum : u8, pkt : NetworkPacket, reliable : bool) void } class Connection{ Connection(max_packet_size : u32, timeout : float, ipv6 : bool, peerhandler : PeerHandler*) # m_udpSocket : UDPSocket # m_command_queue : MutexedQueueConnectionCommandPtr - m_event_queue : MutexedQueueConnectionEventPtr - m_sendThread : std::unique_ptrConnectionSendThread - m_receiveThread : std::unique_ptrConnectionReceiveThread - m_bc_peerhandler : PeerHandler* - m_peers : mapsession_t, Peer * } note top of Connection : final class note top of Connection : session_t-u16 class UDPSocket{ UDPSocket(ipv6 : bool) init(ipv6 : bool, noExceptions : bool) bool Send(dsetination : const Address, data : const void*, size : int) void Receive(sender : Address, data : void*, size : int) int setTimeoutMs(timeout_ms : int) void } class Thread{ # m_name : string - m_joinalbe : bool - m_request_stop : atomicbool - m_running : atomicbool - m_mutex : mutex Thread(name : const string) start() bool stop() bool wait() bool # {abstract} run() void* } class ConnectionSendThread{ - m_connection : Connection - m_max_packet_size : unsigned int - m_timeout - m_outgoint_queue : queueOutgoingPacket ConnectionSendThread(max_packet_size : unsigned int, timeout : float) setParent(parent : Connection*) void sendPackets(dtime : float, peer_packet_quota : u32) void sendAsPacketReliable(p : BufferedPacketPtr, channel : Channel*) void rawSend(p : const BufferedPacket*) void run() void* } class ConnectionReceiveThread{ setParent(parent : Connection*) void run() void* } class PeerHandler{ {abstract} peerAdded(peer : IPeer*) void {abstract} deletingPeer(peer : IPeer*, timeout : bool) void } class Peer{ } class PeerHelper{ - m_peer : Peer } class UDPPeer{ # channels[] : Channel } note top of UDPPeer : final class class Channel{ queued_commands : dequeConnectionCommandPtr } 类关系 Connection --| IConnection : public ConnectionSendThread --| Thread : public ConnectionReceiveThread --| Thread : public UDPPeer --| Peer : public Connection *-- UDPSocket Connection *-- ConnectionSendThread Connection *-- ConnectionReceiveThread Connection *-- PeerHandler Connection *-- Peer PeerHelper *-- Peer UDPPeer *-- Channel enduml