参考知识:
1、负载均衡;
2、IO模型;
3、Netty工作原理;
4、TcpClient;
6、jvm字节码指令分析;
背景:
2013年时,就听到过雪球网在11年的时候,通过node与java的通信完善了前后端分离的实践;到15年初的时候又看到github上分享出阿里巴巴这方面的实践,依托强大的阿里云与阿里大牛的技术支持下,15年阿里整站node化分层,并且当年双11没有出现一起node引起的重大事故,同时阿里也做了性能与工效对比,从阿里分享的资料来看,优势非常明显!
自从来到58的时候我就想着有一天能把这块技术推广!不为别的,只是因为我痛恨没有意义的浪费时间在一些重复性的劳动上,尤其还不能带来社会进步与个人进步的劳动上,对于公司来说也是浪费了很多的人效成本!应该都有体会:假设pm发出一个一般的需求来,前端开发、后端开发各自可能仅用2~4h开发完毕;但是当联调的时候却有可能花费两天以上的时间!
因为不是一个大脑,所以这段距离的交流上就有很长的时间要消耗!
国富论上有句话:“分工的出现能大大提升生产效率,不会虚耗时间在来回奔波上!”
那做这种rpc客户端,跟劳动分工有什么关系呢?同样分离的问题,我完全可以使用其他方式实现!比如http、react、thrift等等,确实有很多方式,而且像58集团也有很多公司在实践,比如英才采用了React、金融使用了http、而转转采用了直接调用node-java模块,都能达到前后端分离的效果,但是也同样有局限性!如果不考虑兼容ie、seo、效率、维护成本、现有状况、可移植性等等问题,确实也有很多的解决方式。比如企业级应用就完全可以使用React等!
详细说来很多可以讲的东西,说服他人确实是一件很难的事,也是推动好久才开始实施的!后来在与其他同事探讨的时候也很难说服!不再扯远了,可以这么理解,打通java与node的通信,能很好的保证业务的再造性,也能很好的分离前后端,包括单页面渲染等。
有一个很好的时间节点,虽然推动了好久才开始进行,但是当工作完成了75%左右的时候,刚好赶上了蚂蚁sofa开源,后期又开源了sofa-node,得以很好的跟蚂蚁的朋友们交流也是不小的收货!
其实如果去看蚂蚁分享出来的文章,写的要好很多,推荐大家可以去看一看《如何实现一个Nodejs RPC》,市面上的rpc框架原理大致相同,都涉及到多路复用的利用,像sofa、hsf、dubbo还有58的scf都是基于netty实现的,相似的还有nginx、nodejs、http2等。
基本原理:
Rpc的原理其实就是基于协议层的通信,最基本的就是tcp协议相关知识!主流的方式无非就是公有协议与私有协议,比如http协议与自定义协议字段的私有协议!http协议很方便且被大多数人所熟悉适合大部分企业使用,但是缺点也很明显,首先他们header头信息就比较庞大,其次如果不是2.0版本效率也不会很高。
私有栈是个不错的选择!当确认好协议方式之后,实际上私有栈仅header头信息就节省出了大量码流;java原生的序列化方式,尽可以抓下tcp包看看他们的组成,会发现一些描述性信息特别多,严重影响效率,所以在编解码方面开源社区也有很多解决方案,比如Google Protocol Buffer。很多公司的私有栈也差不多使用相似的原理实现的,包括sofa与58的rpc。
关于消息在client端与server端的组织形式:
基本原理上也基本相同,大致流程为如下。
定义好私有栈:header、body,说明一下,header的内容包括一些通用字段,比如传输大小,sessionId等等,body里的内容就是要被序列化的对象的内容了;
client端封装参数对象成一个list集合到一个Request里,同时Request对象包含要调用的类与方法名的字段,按照顺序将Request对象属性进行序列化成01表示,然后再已经建立好的nio通道内将数据发送到server端;
server端接收到数据并拆包后,反序列化该对象,根据类名、方法名,找到对应要执行的方法,然后根据业务逻辑将需要返回的数据对象封装到一个Response对象中,进行序列化操作,发送数据到client端;
Client端根据之前定义好的sessionId从得到的码流中拆包得到对应二进制流,然后解码成Response对象给client用户使用。
很抱歉没有开源,Nodejs-scf详尽调用方式与原理我会在内部系统中阐述!
内容:
协议字段定义
定义协议头其实挺容易的,就是定义好指定位置的指定内容就好了。
大小端兼容
涉及到网络传输就必须考虑到大小端,其实可以理解为波传播的先后顺序。数据的高字节保存在内存的低地址中;数据的高字节保存在内存的高地址中。
心跳与重连
当我们正常的通信调用的时候,必须考虑到server端的可用态,与重启之后的重新建立连接的方式。定时15~30s的心跳ping-pong查看可用状态,如果对应配置的连接可用则重新建立连接。
负载均衡
好的负载方式能帮我们更小的减少损失,给用户更可靠的响应。所以选择了最快响应的方式,通过检测调用的超时情况,逐步降低调用方的对某台server的权重,来做到动态调整负载情况的目的。
编解码
详细原理可以看protobuf的源码,市面上的编解码方案很多也大同小异,自定义编解码方案,详细描述会体现在内部系统中,这里不做阐述。
服务管理
并没有选择SM,而是选择当前在用的管理平台的ETCD一致性组件,保证服务节点的实时更新。
质量上报
采用udp定时上报服务请求状况,达到监控的情况。
问题与挑战
类型匹配与精度问题
这是个挺大的问题的!背景:scf(58rpc)对java支持性很好,也没有做到像thrift那样定义通用Schema。
Java是强类型语言,所用类型包括byte、char、int、long、double、float、date、set、list、map、object等;Node数据类型包括Number、string、map、array、symbol。所以如何有效的匹配起来并且能相互表示是一个很大的问题。
针对类型的匹配可以放到后边的“Node端调用方式” 来说,这里主要讲精度的问题。
首先要确定的是在java中,long、double都是8字节的。Node中Number表示6字节。所以如何很好的匹配是个问题。我们尝试过long、另一个日本人写的组件还有big-integer,最终发现似乎只有big-integer合适(后来在sofa开源node版本后,发现long组件也是可用的,可能是我们对long的api不熟悉吧),然后就通过该组件对8字节的数据进行操作,这样就解决了长类型字节匹配的问题,同时也节省了很多时间去做这些字节的操作。
然后涉及到单双精度的问题,为了很好的理解它们的码流方式与计算方式,我编译了一遍jvm的源码,找到了他们的实现方式
switch (id) {
case vmIntrinsics::_floatToRawIntBits:
push(_gvn.transform( new (C, 2) MoveF2INode(pop())));
break;
case vmIntrinsics::_intBitsToFloat:
push(_gvn.transform( new (C, 2) MoveI2FNode(pop())));
break;
case vmIntrinsics::_doubleToRawLongBits:
push_pair(_gvn.transform( new (C, 2) MoveD2LNode(pop_pair())));
break;
case vmIntrinsics::_longBitsToDouble:
push_pair(_gvn.transform( new (C, 2) MoveL2DNode(pop_pair())));
break;
case vmIntrinsics::_doubleToLongBits: {
Node* value = pop_pair();
case vmIntrinsics::_floatToRawIntBits : {
FloatConstant* c = x->argument_at(0)->type()->as_FloatConstant();
if (c != NULL) {
JavaValue v;
v.set_jfloat(c->value());
set_constant(v.get_jint());
}
break;
}
单纯看源码不是很好理解,这样我们就可以同时结合oracle的官方文档
https://docs.oracle.com/javase/specs/jvms/se6/html/ClassFile.doc.html,
其次,我们可以在java源码中发现float、double是有方法可以转成int的,也就是码流上其实无所谓,关键在于一个单双精度的IEEE 754的计算方式上。
我是没有发现node源码中具体的ieee754的实现位置,但是如果在源码搜索ieee754,也能搜出一片内容来,所以基本可以确认,也是遵循规范的。
那么结合big-integer,我们就不需要再去考虑底层位移操作了,直接使用node端提供的writeInt32BE类似这样的方法就可以了。
数据压缩
为了节省码流,我们还有一些优化可以去做,比如按位压缩,像数值这样的定长类型的码流!比如:如果一个int类型的数据,占用4字节,实际上它真实有用的数据只有1个字节,那么我们其实没有必要传输高位的其他字节码;但是对于负数来说可能就不适用了。只要判断出他的高位是不是有用数据,我们就可以做到去除高位无效数据,从而达到压缩的效果。
加减乘除运算的精度问题
前边说了由于精度的不匹配,也可能是我对node中Math的使用不熟悉,本身主流开发是java。
所以为了跟java端运算生成的hash值一致,我采用了位运算来实现加减乘除的方式,这样就可以匹配到对应java的计算值了。
node端调用方式
其实上述内容都花费不了太多时间。最主要的是要定义好更合规、合理、对使用者更方便的形式,所以考虑这些方面花费了不少时间,相比其他模块包括编解码,这块反而花费了更长的时间!
那如何设计一个合规方式呢?我认为宁愿自己麻烦一些,也要给使用的人带来使用的方便!所以我认真再次读取了jvm的源代码,很遗憾,生成字节码与解析字节码的c++文件实在太多,而且太复杂,还不如我挨个看文档看二进制来得快,所以最终采用了nodejs解析对应java的jar包(后来通过交流感觉我应该使用java去解析的),并且解析字节码的二进制文件,通过读取常量池内的内容解析方法、参数、类名、全限定类名、属性值等,然后通过nunjucks生成对应的接口类与实体类对应的js类文件。
为什么要生成对应的接口类文件呢?先看一下部分代码片段的格式吧!
可以看出来,我是将一个java类对象的所有相关属性值生成到js类文件中,这样方便编码的时候找到对应的类型,序列化到码流中。为了使用者在node中更符合使用者的使用习惯,故意在constructor的属性值中没有生成类型信息,对应属性类型信息以及注解等方式,是再次生成在了静态属性中,这样是为了更符合node端的使用方式。也就是生成对应java的元数据类文件。
问题:如果自己从头解析字节码,可能会有些问题,而且时间会比较长。所以采用了github上一个巴西哥们的结构化java字节码的buffer组件,但是这个组件有些问题,首先还是那个问题,就是他对于8字节数据解析有些问题,没有解析成功,其次多参数的参数名称没有解析出来。(抱歉,这个插件没有错误,是不熟悉api,重新看了一下后,发现是用了别的方式表示的,插件没有问题)所以我打算目前支持的类型情况下,之后fork出他的这个组件代码,调整好这几个bug。另外由于jvm对于基本数据类型、包装类、泛型、array以及对象类型的方式上都有些不同,尤其是使用node语言解析,由于时间关系没有解析的很好,未来都会处理这些问题的。
总结:如果要从头开发一个rpc的客户端,关键在于如何在效率与定义方式上寻求方案。
由于一些限制,目前就只写一个大概吧!至此特别感谢蚂蚁的朋友黄庭、晓晨还有朴灵先生,以及腾讯丹华提供的帮助与交流,也特别感谢团队人员的协助,才能逐步实现。
节点:2018.8.14发出node-scf beta版!