本文基于 RustCon Asia 罗超演讲内容整理,基本没有代码,无高能预警,可放心阅读。讲师本人私下里小声哔哔脑子里未出师的段子没有讲出来,但其实内容上还是很直观地让我们了解了 P2P 架构。
这是一个神奇的库,他写得痛并快乐着!
简单介绍区块链底层
秘猿科技的主产品是区块链,两款区块链产品(CITA 和 Nervos Network)的底层网络基于此库开发。主库是 tentacle,内置三个协议:discovery、identify、pling。
为什么要重新实现一个网络库?
目前的 P2P 网络库比较少,对我们的产品来说网络是十分重要的一部分,已有的库无法满足正常需求。例如协议的更新同时要兼容原来的协议,这就需要一个框架去支持把一个真实链接拆成多路复用,可以不停地打 Patch。旧的协议支持一年半载,然后关掉更换新的协议,这方面的需求会很多。目前来说,社区的支持相对比较少,只能重写。
我们对这个框架最大的夙愿就是可以实现动态的更新,支持大量的可定制化、不停地更新协议。
对网络库的基础要求:轻量、简洁、可靠、高性能、对用户友好。 抽象的实现很重要,但如果把抽象一股脑地抛给用户,用户可能没办法很好地使用。所以写框架的时候不仅是实现功能,我们也要自己使用它,同时让别人去使用来给予反馈。
基于以上的需求,我们讨论之后得出下图架构:
这是我们库目前的架构,自上而下分为四层 ,最下面一层是 Transport,它的作用是支持现有的协议,可能是 TCP 、KCP 或者一些现有的常见协议,实现在这些协议的基础上可以支持自定义的上层协议;往上一层是 Secio ,实际上它还是有一定的局限性的(后面会提及);再上一层是 Yamux,它是 libp2p 的一个子协议,它的作用是实现多路复用;最上层是 P2P ,它是对下面三层的整合,导出一个对用户相对来说比较友好的 API。这个四层架构和 HTTP(它的最下层是 TCP,往上一层是 TLS 加密,再上层是 HTTP 的一些自有协议)看起来是非常相似的。
愿景基本上是这样,但代码真正实现的时候呈现的是这样的架构图:
代码框架与 Future 模式
实际在理清架构、写代码的时候碰到一个问题,就是在实现网络时不得不去用异步的模式写一个网络库。而在 Rust 社区里面,future 的使用相对其它语言来说会比较底层一点,写起来会相对比较复杂一点。在这个过程中,我们考虑了可以用三种模式去写。
第一种模式就是比较常见的:一层一层去封装,最后封装成一个巨大的 future 状态机。这样每一层里都可以有它自己的状态,在实现的时候,每一层只关心自己,然后把底层的资源放在一个结构体里面就行了。这种方式最后的构建是一个非常大的状态机(如果有很多层的话)。
这种方式对于实现多路复用会有一个问题,最底层可能需要 Arc+lock,目前异步的生态基本都是运行在 Tokio 的 runtime 上,为了性能肯定是要多线程编程。而在多线程编程的情况下,使用锁的时候不要说异步,即便是同步也都会碰到一些奇奇怪怪的问题。所以就算这种方式能实现多路复用,还是选择了舍弃这种方式,因为锁的机制很有可能无法把控。
第二种模式就是在一个多路复用的协议里用 Channel 分发的模式实现。这种模式需要的底层资源除状态之外,还需要 sender 和 receiver, receiver 接收上层传过来的 event,sender 把底层的 message 通过 index 的方式丢给上层,这种方式相对于第一种方式的优势是每一个 future 都是单独的,天生的多核利用能力,十分契合 Tokio 的 work-steal runtime 模型。劣势是代码相对来说会比较复杂一点。
第三种模式是不管 future 、task 是什么,全部转成一个 trait object 放在一个地方。这种方式是有应用场景的,但是如果作为一个 Service 的 stream(流式数据)去做,会出现调度不均和响应不及时的问题,而且多核的利用率是极低的。这种方式对于一次性的多个任务是可以推荐的,但是如果是一个常驻的流式数据,尽量就不要用这种方式。
详细介绍 P2P 库
Transport 层 ,目前来说它实现了 TCP,我们把 TCP 包装了一下,导出了 multi-Incoming、multi-transport、multi-stream、multi-listen、multi-dial。实际上就是简单封装一下 Tokio 提供的 tcp_listen 和 tcp_stream。
这里插一个关于底层多协议支持的小调研:
在半个月之前,我打算去实现(支持)WebSocket 的时候,又碰到一个问题。社区里目前有两个 WebSocket 的实现,一个叫 WS。WS 对我来说的问题是什么?它是直接基于 MIO 去做的,这与 Tokio 生态是脱节的,如果要去包装它的话,需要在一个地方起一个 MIO,另一个地方用 Tokio 再起一个 MIO,两个 MIO 在下面跑。这种方式对我来说是不能接受的,所以这个库基本上就不在我的考虑范围内了。
另一个库的名字就叫 WebSocket,看上去好像基本上没什么问题。但是在看它的依赖的时候发现一个很神奇的地方,它在解析 HTTP 头的时候,为了方便,依赖一个 Hyper 0.10 版本的库,依赖过于巨大、沉重。去查它的 issue 的时候,他们是有打算换成一个从 Hyper 中抽出来的叫 http-passer 的库,但是主要贡献者还没什么动静,这样我就比较担心库的后续维护问题。经过了一番调研之后,我们产品就目前来看对 WebSocket 底层的需求可能是未来一年之后,对于底层多协议支持问题不是很迫切,就暂时搁置了这个问题。
Secio 层 ,它的实现相对比较简单,我们把底层给的 fd,通过加密解密,往上抛或者往下抛,然后在上层导出一个跟底层类似的 fd 的 handle。
但是加密通信最重要的根本不是长期跑的状态,而是我们怎么去让它实现动态生成一个加密的共享私钥。下图就是经典的加密握手的三次过程:
首先是交换公钥,一个随机数,一个 nonce,一堆提议之后,双方确认正常,生成一个临时的非对称加密的公钥和私钥,然后签名,把公钥发给对方,确认公钥是正常的情况下,通过对方的临时公钥和自己的临时私钥生成一个共享加密私钥,最后把一开始生成的 nonce 发给对方,确认握手是没有问题的。
这是一个非常经典的实现,基本上可以说是教科书式的。加密通信是实现了,但是我们无法防范中间人攻击,比如私钥丢了或者其它情况,我们无法确认身份。在 HTTPS 有「证书」的存在,但是我们的场景里面,无法找到一个真正的利益无关的中间人去发证书,这里就是上边提到的局限性问题。
Yamux 层 ,它是一个多路复用层,和 Secio 层其实是相似的,Secio 层是把一个 fd 转成上层的一个fd,Yamux 层是把一个 fd 转成上层的多个 fd,基本就是参照着 Yamux 的 spec 去实现的。对这个协议比较感兴趣的,可以去看 go- yamux 里面的一个 spec 文件。
P2P层 ,它有三个职责,第一个职责是把下面所有资源整合起来,第二个职责就是对外提供一个相对友好的 API。第三个职责是把下层传上来的多个 fd 去绑定一个对应用户的 Protocol,提供一个 Protocol 的抽象。
相对于下面三层,P2P 层写起来是最复杂的过程。Transport 层只是一个封装,So easy!Secio 层是经典的加密握手,对着写就好。Yamux 层是多路复用,有协议有实现,把它 report 过来,接口写好就可以了。但是 P2P 层没有任何参照,需要自己去试。这一层其实在稳定(基本可用)的状态是三个月之前,过去的三个月我们一直没敢发 0.2,为什么?不停的在调 API。虽然产品是在用的(发了 Alpha 版本),依赖这个版本在往上走。这个库得很神奇的是别人的 API 是稳定的,我们的 API 应该是快稳定了(底层协议的通信是稳定的)。
简单来说一下这一层的结构:
上图右边的三个 Substreams 堆就是三个真实的连接,把消息全部转到一个叫 Service 的控制器里面,然后再从控制器里面把这些东西传给用户定义的 ProtocolHandle 里。
这里其实纠结了很多,我们一开始是这样认为的:当前的 future 就比较 low-level,不管是从其它语言转过来的或者是新学的,能够真正上手去操作 future,其实是挺难的,那么我们就想给用户提供一个 CallBack 模式,CallBack 模式是很常见的,基本上像 js 或者其它都会有,我们就定义了一个 ProtocolHandle,每个 ProtocolHandle 的 API 调了很多次。用了一段时间之后发现 Protocol 没有问题 ,但是 Service 可能自己会报错的。比如说报错 ListenError、died 、timeout 等等。所以我们又定义了一个 ServiceHandle ,专门处理一些不是 Protocol 内容的东西,比如 SessionOpen、 ListenStart,ListenError、 ProtocolError 等等。
接下来我们发现一个单独的 ProtocolHandle 是不够的,可能会有各式各样的需求,比如需要常驻有状态的 ProtocolHandle 去支持所有的 Protocol 处理,这个时候我们就需要缓存。而有些时候协议比较简单,就可以提供一个无状态的 ProtocolHandle,某个 Session 被打开了,对应的 Protocol 就被打开了,某个 Session 被关闭了,对应的 Protocol 就被关闭了,这是一个非常轻量的 handle。因此我们就有了 ServiceProtocolHandle 和 SessionProtocolHandle,但在上层或者自己写的时候,又发现我们需要在 Protocol 之间共享一些东西,又要用到锁,如果一不小心又会死锁,那怎么办?
就有人提出,可以把所有的东西都通过一个事件抛出来,就支持了 Event Output 和 CallBack Output,一起抛。这样以来目前的状态基本都支持了,但是会导致上层在用库的时候分成两派。一派认为并发是重要的,可以用 CallBack 去依赖 P2P 库支持多线程并发模式; 另一派认为状态是最重要的,而调度可以掌握在自己手里。那么通过 event 把东西全部输出,在外面做一个控制器,之后再并发。我们的两个主产品,实际上使用的是两种不同的风格。
在这一层很重要的一点,就是开始说的我们需要抽象 Protocol 的概念。 Protocol 的概念实际上是绑定了 Substream,那么如何知道一个 Substream 就是一个 Protocol ,实际上还需要类似于加密握手的过程(Protocol Select 的过程)。客户端会发送想要的 Protocol 给 Service,Service 确认支持的情况下,就把 Protocol 的 Name 发回去,如果不支持就 Null 回去,这个 Substream 就断了。
在 P2P 框架里有一个必须要解决的问题:虽然不能信任网上的任何人(任何节点),但实际上我们需要节点自发现。它和微服务框架的 Service Match 概念上差不多,但是实践是不一样的。Service Match 里会有单一的注册器或者 ETCD 的分布式注册器,然后把信息注册上去。但是在我们的产品里面,我们不可能提供类似的注册器,就需要有不可靠的自发现。
例如 A 和 B 连接之后交换一下,我们认为他们是已经存储的节点,接下来 C 连接上了 B ,B 和 C 交换,双方已经知道节点。之后会有第二次操作,B 定时查看当前已经连接的节点,然后将 C 的信息告诉 A ,再将 A 的信息告诉 C,这是一个相对来说比较简单的自发现过程。实际上我们对网络上任何消息都是持怀疑态度的,需要自己与上层的协议确认是否可靠,比如尝试去拨一下,看它是不是真的,再做其它处理。
在写库的时候会有比较棘手的问题,我们不能信任何的东西,产品的客户端是可以由任何语言去实现,可能是异构的。这种情况下,它实现的协议标准和我们一样,但是他可以做恶,做恶的时候我们需要有防范机制。这在P2P里面是没有做的,因为我们的库里面实际上就提供了一个类似于路由一样,把 A 协议安全转向 B 或者把 B 协议安全转向一个其它节点。
在实测 P2P 框架的时候,我们在全球范围内租一些云服务器,然后在各大区里面测试。测试的结果 P2P 目前来说是稳定的,但是上层的处理跟不上 P2P 的交换速度。很有可能消息会累积在下层,因为上层需要验证的东西过多,和上层自己发的垃圾消息太多了,基本上来说目前算是稳定了。
以上就是本次 RustCon Asia 罗超的演讲内容了,欢迎大家讨论。
完整PPT 查看:Implementing a p2p network framework - Google 簡報
完整视频查看:RustCon Asia 2019 - Luo Chao: Implementing a p2p network framework_哔哩哔哩_bilibili
关于罗超
GitHub:https://github.com/driftluo
Blog:https://www.driftluo.com