如何手写一个node版的rpc协议

942 阅读6分钟

网络7层协议

20211215160435.jpg

20211215155724.jpg

如果按照5层模型来分的话,应用层+表示层+会话层 = 应用层 我们可以看到http,rpc协议是属于应用层协议

为什么网络要分层

复杂的程序都要分层,这是程序设计的要求。就像从操作系统 -> 浏览器 -> h5应用,也是一层一层的剥离开的,就像手机制造,你也不可能从原材料开始就直接自己怼,分层的目的就是让将复杂的问题一一拆解开来解决,所以网络要分层,底层的协议和硬件设备和更贴合一点,而应用层是和业务项目等更贴合。所以也能看出应用层的协议也更多一些。而这么多应用层协议也是为了满足不同场景下的不同诉求。

rpc调用(rpc协议)与ajax调用(使用http协议)的区别

相同点
  • 都是2个计算机之间的网络通信
  • 需要双方约定一个数据格式
不同点
  • ajax主要用在浏览器和服务端的调用,rpc主要是用来内网服务器之间的互相调用(更快更安全);之所以内网服务器之间使用rpc调用,是因为rpc调用一般是二进制传输,需要辩解码的对象是二进制数据(计算机处理起来更快),而rpc调用也更加安全(协议可以自己定)
  • ajax主要是用http协议(传输方式主要是json,xml,html),rpc调用需要自己去定义一个协议(二进制协议,传输解析编码更快);
  • ajax使用dns寻址(浏览器面向普通用户,所以你得对很多网址进行ip解析,用户肯定不愿意直接浏览器输入一个ip进行页面的访问,一定程度上损耗了一些性能),而rpc调用则不需要这种寻址(完全可以在内网自己整一个map通过id或者其他标识进行映射即可)

说几个场景,加深一下印象

  • 当后端的微服务要互相调用接口时,这种情况下,rpc调用非常合适
  • 当一个rpg游戏客户端与后端进行频繁的数据通信时,此时如果用http协议显得非常的慢,游戏体验肯定很差,这时候可以用websocket + protobuf来模拟rpc调用。

撸代码前想一想

  • rpc调用使用二进制传输数据,所以核心要处理的就是编解码数据,这里想一下如果是编解码json该怎么操作?JSON.stringify, JSON.parse一下即可,非常方便。如果是编解码二进制该如何操作呢?
  • rpc调用其实可以实现单工通信,半双工通信,全双工通信这几种模式的通信,那么他们有什么区别,为什么会需要这几种方式?
  • rpc调用中双工通信中的序列化,粘包,该怎么处理(TCP底层传输二进制时,会将同时发出的几个包进行粘包,然后统一发出,也可能会将一个大的包分成几份发出去)

带着上面的问题咱们撸一撸代码

image.png 先整个node项目,我这里创建了3个项目,分别是双工通信,半双工通信,和单工通信。这里要实现的rpc其实是node之间的互相调用,如果是node和java调用,则还需要和java后端约定协议的格式,以及2个语言之间的差异(譬如对long number的处理)

围绕着上面的第2个问题,这几种通信方式说来也简单。

  • 半双工通信 = 独木桥(同时只能从一端向另一端发送数据);
  • 单工通信 = 单向行驶单车道(只能固定从一端向另一端发送数据);
  • 双工通信 = 双向多车道(2端可以随意向另一端发送数据); 由次可见,大家会觉得双工通信多牛逼啊,用这一个就完事了,其实存在即合理(就像生活中有单向单车道,独木桥,双向多车道一样。。。都是根据实际的需求以及实现难度来进行合理的选择)。

这次主要一步一步实现最难实现的双工通信。

我们先让客户端和服务端进行连接

image.png

image.png

rpc对二进制的编解码是很麻烦的,尤其是对一个复杂的数据结构进行二进制写入。需要根据数据的类型长度进行判断,然后依次写入,然后偏移继续写入下一个字段,解析的时候也要根据约定好的数据格式进行一一读取,在塞入对象里。我们可以使用protobuf规范(由js实现版本),node版本我们可以使用protocal-buffers。

rpc中双工通信比较常见的问题就是假如客户端同时发送多个请求过去,后端的处理时长时不一定的不一定是先发送的就先回来,如果客户端是根据发送的先后顺序来处理后端返回,显然就会出错,这种情况比较好解决,只需要在每个包前加一个seq数字,后端处理完之后把seq原封不动返回即可。粘包问题其实也很好解决,每个包里面加一个bodylength,后端如果接受到一个大的包就可以根据bodylength进行切割,然后再一一处理。

这里主要实现一个用protocal-buffers进行数据编解码,然后通过seq序列化保证数据不出错。

  • 安装protocal-buffers

image.png

  • 书写proto文件(传输的数据格式,用来编解码用的)

image.png

  • client.js server.js代码(直接看代码注释很多)
// client.js 代码

const net = require('net')

const fs = require('fs')

const protobuf = require('protocol-buffers')

// 用protobuf实例化一个解析器,解析器会包含encode和decode函数,可以理解为生成了一对JSON.stringify和JSON.parse函数
const schemas = protobuf(fs.readFileSync(`${__dirname}/member.proto`))

// 客户端要去主动连接服务端,所以创建的是一个socket
const socket = new net.Socket({})

// 连接服务器
socket.connect({

    host: '127.0.0.1',

    port: 4000

})

const memberIds = ['1', '2', '3', '4', '5']


// 包的id,自增
let seq = 0

// 编码函数,客户端发送时,只发送了id过去人员的id过去
function enCode() {

    // 随机一个人员id
    const memberId = memberIds[Math.floor(Math.random() * memberIds.length)]


    // seq自增
    seq += 1


    const header = Buffer.alloc(4)

    // BE是大端字节序 LE是小端字节序(这玩意就是各个硬件厂商搞的,就是字节编码是是从高到低排还是从低到高排)
    // 网络协议都是采用大端编码的方式进行传输数据,所以这里我们使用be的api
    header.writeInt32BE(seq)


    const body = Buffer.alloc(4)

    body.writeInt32BE(memberId)

    // 打印出seq以及memberId
    console.log('发送:seq, memberId', seq, memberId)

    // 将header,body数据打包发送
    const reqBuffer = Buffer.concat([header, body])

    return reqBuffer

}


// 解析后端返回的数据,这里有人员信息
function deCode(buffer) {

    let resStr = ''

    // header存放了seq
    const header = buffer.slice(0, 4)

    // 这个body是包含了memberId以及memberInfo,memberId在前4个字节
    const body = buffer.slice(4)

    // memberInfo数据在第8个字节之后
    const bodyMember = buffer.slice(8)

    // 读取seq
    const seq = header.readInt32BE()

    // 读取memberId
    const memberId = body.readInt32BE()

    // memberInfo需要用再进行decode处理
    const memberInfo = schemas.Member.decode(bodyMember)

    // 打印接收到的数据
    console.log('接收:seq,memberId,memberInfo:', seq, memberId, memberInfo)

    return memberInfo

}


socket.on('data', (buffer) => {
    // 收到服务端数据,进行解析
    const info = deCode(buffer)
    // socket.write(enCode())
})


// 每隔500毫秒会向服务端发送一个请求人员信息的数据包
setInterval(() => {
    socket.write(enCode())
}, 500)


/**

* 这是服务端代码

*/

const net = require('net')

const fs = require('fs')

const protobuf = require('protocol-buffers')

const schemas = protobuf(fs.readFileSync(`${__dirname}/member.proto`))

// 可以理解这是个数据库里的数据
const datas = {

    '1': {

        name: '我是1号',

        sex: '男',

        age: 18,

    },

    '2': {

        name: '我是2号',

        sex: '女',

        age: 20,

    },

    '3': {

        name: '我是3号',

        sex: '男',

        age: 26,

    },

    '4': {

        name: '我是4号',

        sex: '男',

        age: 28,

    },

    '5': {

        name: '我是5号',

        sex: '男',

        age: 29,

    }

}


// 创建一个server服务
const server = net.createServer((socket) => {

// 这里回调回来的事socket,也很好理解,服务器会处理各种其他服务器(或者客户端)和自己建立起的连接,
// 每一个socket可以了解为是该服务器和客户端建立起的一个通信通道(之后的通信都是通过这个通道进行的,各个socket之间互相独立)

socket.on('data', (buffer) => {
    // 对每个请求的反应时间不一样,加一个随机数
    setTimeout(() => {
        // seq
        const header = buffer.slice(0, 4)
        
        // memberId
        const body = buffer.slice(4, 8)

        const id = body.readInt32BE()

        const info = datas[id]

        // console.log('info:', info, schemas.Member.encode(info), Buffer.concat([header, body, schemas.Member.encode(info)]))

        // 将seq,memberId,memberInfo依次塞入buffer,和client的decode函数一一对应
        socket.write(Buffer.concat([header, body, schemas.Member.encode(info)]))

    }, 500 + 1000 * Math.random())

  })

})

// 监听4000端口
server.listen(4000)

看下打印结果

image.png 可以看到序列为3的请求返回的数据是正确的,这里是无序请求,服务端也是无序返回。

收尾

rpc协议远没有这么简单,需要处理的事情还很多,不过我们要始终遵循一个原则就是适可而止(You Ain’t Gonna Need It,YAGNI原则) 所以找到适合你们业务的rpc协议即可。