文章目录
- 前言
- 阅读前须知
- rpc是什么?
- 别的进程 vs 别的机器
- rpc的目的或是我们为什么需要rpc?
- 实现rpc所涉及到的底层技术
- 1. 通信技术(网络IO、Network IO)
- 套接字(Socket)
- bio、nio与Netty
- 2. 网络协议(字节数据解析技术)
- 固定长解析(Fixed Length)
- 编码与解码
- 序列化与反序列化技术(Serialization & Deserialization)
- rpc调用时需要传递的核心信息
- 结语
前言
在企业级系统架构的发展过程中,把单一应用做拆分是一个大趋势。单一应用我们可以理解其为单个进程,单一应用架构在应用体量变得庞大时会面临难以维护、依赖项过多、性能低劣等问题,把单一应用里从属不同领域的服务拆分到别的应用中变得非常有必要。无论是竖向的按产品拆分还是横向的根据职责拆分都会拆分出多个小应用(这些也都是进程)。那么简单理解rpc的话,就是在横向拆分时出现的一个需求。本文将围绕rpc的基本需求去浅析rpc的原理
阅读前须知
本文旨在帮助读者建立对rpc的基本认知,不会从现有商业rpc产品的源码角度去具体分析他们的实现。通过阅读本文你会对以下概念有一些基本认知。
- rpc
- 网络协议
- 套接字
- bio、nio、Netty
- 编码与解码
- 序列化与反序列化
rpc是什么?
要理解rpc,首先我们需要知道rpc到底是个什么玩意儿,那么rpc到底是什么呢?
rpc,全称remote procedure call,中译为远程过程调用。用白话来讲就是在别的进程上执行计算任务,因为不在当前进程上执行,是为远程。
别的进程 vs 别的机器
需要注意的是有一个常见的误区,这里提到的是别的进程,并不是别的机器,这意味着你可以在同一台机器上同时部署rpc服务消费者和rpc服务提供者,当然一般生产环境中还是以调用其他机器上的进程为主。
rpc的目的或是我们为什么需要rpc?
我们知道了rpc是什么之后,我们还需要知道为什么要用rpc?在前言部分我们也提到了rpc出现是因为业务系统的横向切分导致的。什么意思呢?
设想我们有一个基于http协议的Restful API服务器,我们收到来自外部客户端的请求通常需要经过几层
- tomcat之类的服务器组件接收外部连接和数据(字节数组byte[]),解析并包装为如J2EE标准的调用
- spring web之类的网络应用框架负责接受J2EE标准的调用,并通过如适配器adapter、分发器等内部组件分发和适配J2EE请求到咱们开发者写的@Controller类的各类方法里。
- 咱开发者编写的@Controller类的各类方法接收到请求后,需要去根据业务需求去调用不同的@Service去完成任务。
- @Service服务层接受到请求后去@repository数据访问层请求数据和做一些运算。
- @Repository数据访问层去如redis缓存、关系型数据库、elastic search服务去存取数据。
那么如果我们把@Service的计算任务交给另一个进程去做,我们的应用就变成两个应用,具体表现是两个进程。
一个进程对外提供http restful api服务,另一个进程提供数据的计算服务。为了能让这两个进程能够协同工作,就需要rpc技术了。
实现rpc所涉及到的底层技术
rpc是相对比较高层或中层的一个概念,要实现rpc需要一些偏底层的技术。这些底层技术都是些什么呢?
1. 通信技术(网络IO、Network IO)
文章读到这里,不难发现我们的rpc需要是在不同进程、特别是不同物理服务器的不同进程之间需要做数据交流(即:通信),那么毫无疑问需要涉及到通信相关技术。我们这里不谈定位机器的IP协议、路由器、私有网络、DNS域名寻址服务等更底层的东西,我们谈应用级别通信技术:套接字(Socket)。
套接字(Socket)
套接字这个翻译说实话并不利于中国人理解,笔者在这里明确一点Socket套接字其本质是一个文件,外部Client端给某个Socket传递数据本质就是向这个文件写入数据,那我们的Server端再从Socket文件取数据这就是我们网络IO数据传输的一个本质。socket文件里数据的读写功能是由OS提供的。那么既然Socket本质是文件,打开很多套接字(即:建立多个连接) 会有问题吗?
答案是会有问题,在一些Linux操作系统默认配置下一个进程可以打开的文件数量是有上限的,一般是1024个。
所以如果打开太多的文件就会遇到“open too many files”错误,这个错误可以通过修改操作系统配置去解决。
读者可以自行搜索如何解决,笔者这里只是简单提一下。国内随便百万级别并发的话,1024个实在是太少了,几万都不够看的。
然后因为如TCP、UDP协议的限制,只有16个bit用于表示端口信息,所以一个机器的端口数量最多为2^16(65536个),
也就是一个机器的总并发连接数量其实被限制在了几万,所以如果To C应用不做集群几乎不可能满足国内这么大的并发连接需求。
bio、nio与Netty
前面我们提到了套接字本质是文件,那么我们应用程序要接受外部的数据的任务就变成了从Socket文件里里读取数据。那么从文件里读数据呢,操作系统就提供了很多种方式有:
- bio(blocking io):读数据的线程会被阻塞直到数据读取完毕,也就是Socket文件直到外部写入到读取完成为止会阻塞我们的工作线程。
- nio(non-blocking io):读数据时不再阻塞,而是首先询问操作系统有哪些Socket文件准备好数据读取了,然后针对这些准备就绪的文件利用bio进行一个读取。其IO模型被称为多路复用IO模型。
- aio(async io):异步io目前还不成熟且用得比较少本文不谈。
关于bio,假设你需要从10000个socket文件里读取数据(1万的并发),如果运气不好,我们开的假设16个工作线程去读的Socket文件,每次都等了很久数据才准备就绪(数据是来自外部,从网卡来的),那么你的应用整体大部分时间都处在等待的一个状态,这是非常低效的。
关于nio,还是上面的1万并发的例子,你只需要一个线程就能满足向操作系统轮询哪些Socket文件准备就绪的任务。比如你某次询问,OS回答有177个Socket文件准备好读写了,这时这个线程再派发这177个Socket文件的读写任务到工作线程,这相比仅利用bio的方式就根本没有工作线程等待io过程。需要注意这些工作线程读取Socket文件的方式依然是bio的,速度大幅加快的原因是因为没有等待的这个过程。nio也是目前可以说所有的分布式框架、应用都用到的技术。
关于Netty,操作系统提供了bio、nio的支持,开发者需要使用的话还需要语言层面的支持,比如Java JDK的java.nio包里的Selector之类的,而JDK中nio相关的API学习成本高及难以使用是一个问题,所以Netty出现了,Netty底层使用nio,对其使用者封装了一些便利好用的接口方便Java开发者去进行网络编程。选择Netty来进行网络编程的话无非就是会引入额外的Netty依赖,所以如Tomcat服务器就没有使用Netty而是直接使用nio,而如dubbo呢就是用的Netty。
2. 网络协议(字节数据解析技术)
通过上面的通信技术,我们已经能够在应用内拿到请求原始数据(字节数组byte[])了,把这些字节恢复成或者说转换成我们一个开发者可用的数据结构(Java对象、Java原生类型等)就是网络协议的主要任务之一了。通常解析字节数组我们有很多种方式。
固定长解析(Fixed Length)
固定长解析,或者说固定格式解析,这种方式通常被用来解析协议头(byte[]数据里最开头的部分)。协议设计者为了减小协议数据包的大小,通常存储信息最小单位是按bit来的,这种解析方式里位运算会使用得相对较多。
比方说我们可以看一下TCP头长什么样。在下图中可以看到第一行4字节(32位)数据,前16位是Source Port,后16位是Destination Port。顺带一提因为用来存储端口信息的数据位只有16位,这也是为什么端口数量至多是2^16(65536)个的原因。
编码与解码
编码解码,比较常见的应用场景是字符串和字节数组互相转换。当然不限于此,比如流媒体领域也有非常多的编码解码,字符串编码解码通常是如下的一个处理过程:
- 编码:encode(String) → byte[],把字符串转换成字节数组;
- 解码:decode(byte[]) → String,把字节数组恢复成字符串;
常见的字符串编码解码方式:
- 万国码utf系列的如utf8、utf16等;
- 日本的jis、shift-jis系列;
- 中国的国标GB系列;
大家接触最多的可能就是http协议中body如果是json字符串,那么其编码方式在rfc中被规定为了utf8,也就是说如果你来开发http服务器/客户端 组件,你在http头里看到数据是json时,用utf8解码就可以获得json格式的字符串了。当然如果你还想要获取json格式字符串里面具体的数据,那么你需要做文本解析,各类XX Language都有的语法解析器那一套了,本文不谈论这个。
序列化与反序列化技术(Serialization & Deserialization)
那么其实呢序列化与反序列化与编码与解码是非常类似的概念,你甚至可以把编码理解为字符串对象的序列化。序列化通常指的是在OOP语言中,把某一对象实例转换成byte[]用于网络传输或持久化到硬盘里。反序列化则是其逆向操作,把byte[]在别处恢复成对象实例。
- serialize(obj) → byte[],把对象转换成字节数组;
- deserialize(byte[]) → obj,从字节数组中恢复对象;
rpc调用时需要传递的核心信息
我们了解了实现rpc的一些技术手段之后,就是需要思考我们在实现rpc过程中需要在rpc服务消费者(调用方)和rpc服务提供者(被调用方)之间传递什么样的数据了。
这个其实很好想到,无非就是调用方需要告诉被调用方我到底要调用哪个接口的哪个方法,调用参数等信息。那么被调用方完成计算任务后需要返回结果,这里面无非是一些被序列化的数据或是被序列化的Exception异常信息。
- 调用方 → 被调用方:{“方法”: “java.lang.String#equals(String)”, “参数”: “abccc”, “实例名”: “xxxObj”}
- 被调用方 → 调用方:{“调用结果”: 成功, “结果”: true} 或是异常 {“调用结果”: “异常”, “异常”: “java.lang.NullPointerException”}
笔者这里为了方便大家看是用了JSON格式字符串来作为信息的载体,但是呢字符串编码的方式会有数据包较大和二次解析(byte[]到String再到Obj)的一个问题,通常主流rpc框架会选用序列化而不是字符串编码的方式来传递数据。
其实有了上面的rpc核心信息和实现rpc的底层技术的概念之后我们就能实现一个自己的简单版rpc工具了。
结语
本文没有讨论任何市面上主流rpc框架的具体实现,仅仅讨论了rpc的概念、rpc实际的运作原理和其所需的核心信息。通过本文笔者希望我们大家都能理解rpc的本质到底是什么,这样无论是面对阿帕奇的dubbo、谷歌的gRPC亦或是老旧的SOAP,咱们都能快速的理解其到底是在干什么。
商业产品无穷无尽,唯有理解其本质才能以不变应万变。我是虎猫,希望本文能帮到你。