消息中间件解析 | 如何正确理解软件应用系统中关于系统通信的那些事?

苍穹之边,浩瀚之挚,眰恦之美;悟心悟性,善始善终,惟善惟道! —— 朝槿《朝槿兮年说》

写在开头

随着业务需求的发展和用户数量的激增,对于互联网应用系统或者服务应用程序则提出了新的挑战,也对从事系统研发的开发者有了更高的要求。作为一名IT从业研发人员,我们都知道的事,良好的用户体验是我们和应用系统间快速反馈,一直以来都是我们考量一个系统是否稳定和是否高效的设计目标,但是保证这个目标的关键之一,主要在于如何保证系统间的通信稳定和高效。从而映射出,如何正确理解软件应用系统中关于系统通信的那些事?是我们必须了解和理解的一项关键工作,接下来,我们就一起来总结和探讨一下。

基本概述

要想理解系统服务间的交流,拿我们人与人的交流来做类比是个不错的选择。我们都知道,人与人之间的实现交流的基本元素主要有以下几个方面:

  • 能够相互听懂和理解的交流语言(即双方要基于相同的"协议"之下)
  • 必要的传播介质(实在的物理介质,空气纸张等都行)
  • 约定好的处理信息的方式(常见的一问一答 或是先记录后处理等表现形式)

从而得知,系统服务间的交流的主要表现在以下几个方面:

  1. 相同的通信原语:就像人类互相需要使用相同的语言进行交流,计算机服务也必须使用互相能识别的消息格式进行交互。
  2. 传播信息的介质:人类交流时往往需要某种介质传播信息,如空气、纸张甚至是眼神等。同样的,网络信息的传递也需要物理介质的帮助,以及工作在其上的一系列相关协议。
  3. 处理信息的方式:人类交流时可以是面对面直接问答形式的,也可能是邮件、短信等延时应答形式的,对应的是不同的业务场景,在计算机里进行通信处理方式。
  4. 实现通信方式:根据不同的协议都能实现通信功能的方式,一般基于一种或者至少一种协议实现。

组成要素

实现系统间通信主要的三个要素:通信格式,通信协议,通信模型。

根据人与人的交流的构成要素,抽象成计算机系统服务中对应的概念(行之有效的概念往往是简单且趋同的),系统间通信主要考虑以下三个方面:通信格式,通信协议,通信模型。具体详情如下:

  1. 通信格式(Communication Format): 主要是指实现通信的消息格式(Message Format),是表达消息内容等基本表现形式。常用的消息格式有xml,json,TLV等。
  2. 通信协议(Communication Protocol): 主要是指实现通信的网络协议(Network Protocol)。常见的TCP/IP协议,UDP协议等。
  3. 通信模型(Communication Model): 主要是指实现通信的网络模型(Network Model)。常见的模型主要有阻塞式通信模型,非阻塞式通信模型,同步通信模型,异步通信模型。

接下来,我们来详细解析这些组成要素:

  1. 对于消息格式来说,是帮助我们识别消息和表达消息内容的基本方式:
    • XML:和语言无关,常用于对系统环境进行描述,如常见的maven仓库配置,或者spring配置等。
    • JSON:轻量级消息格式,和语言无关。携带同样的信息,占用容量比XML小。
    • Protocol Buffer:Google定义的消息格式,只提供了java,c++和python语言的实现。
    • TLV:比JSON更轻量级的数据格式,连JSON中的"{}"都没有了。它是通过字节的位运算来实现序列化和反序列化。
  2. 对于网络协议来说,是帮助我们实现消息传输和传递的表达方式:
    • 数据在网络七层模型中传递的时候,在网络层是"数据包",在数据链路层被封装成"帧"(数字信号),在物理层则是"比特"(电信号)。
    • 不同的协议都能实现通信功能,最适合本系统的通信协议才是最好的。
  3. 对于网络模型来说,主要是帮助我们理解和选择适合当前场景的应用框架:
    • 在计算机网路层面来说,常见网络模型主要有OSI 参考模型和TCP/IP 模型两种。
    • 除此之外,还有Linux 网络I/O 模型和Java JDK中的I/O 模型

网络协议

我们用手机连接上网的时候,会用到许多网络协议。从手机连接 W i F i 开始, 使用的是 8 0 2 . 11 (即 W L A N ) 协议, 通过 W L A N 接入网络; 手机自动获取网络配置,使用的是 D H C P 协议,获取配置后手机才能正常通信。这时手机已经连入局域网,可以访问局域网内的设备和资源, 但还不能使用互联网应用,例如:微信、抖音等。想要访问互联网,还需要在手机的上联网络设备上实现相关协议, 即在无线路由器上配置 N AT、 P P P O E 等功能, 再通过运营商提供的互联网线路把局域网接入到互联网中, 手机就可以上网玩微信、刷抖音了。常见的网络主要有:

  1. 局域网 : 小范围内的私有网络, 一个家庭内的网络、一个公司内的网络、一个校园内 的网络都属于局域网。
  2. 广域网: 把不同地域的局域网互相连接起来的网络。运营商搭建广域网实现跨区域的网络互连。
  3. 互联网: 互联全世界的网络。互联网是一个开放、互联的网络, 不属于任何个人和任何机构, 接入互联网后可以和互联网的任何一台主机进行通信。

简单来说,就是手机、无线路由器等设备通过多种网络协议实现通信。网络协议就是为了通信各方能够互相交流而定义的标准或规则, 设备只要遵循相同的网络协议就能够实现通信。那网络协议又是谁规定的呢? ISO 制定了一个国际标准OSI , 其中的 OSI 参考模型常被用于网络协议的制定。常见的网络协议:

  1. 面向连接协议(TCP协议):在发送数据之前, 在收发主机之间连接一条逻辑通信链路。好比平常打电话,输入完对方电话号码拨出之后, 只有对方接通电话才能真正通话,通话结束后将电话机扣上就如同切断电源。TCP协议是一种面向有连接的传输层协议,能够对自己提供的连接实施控制。适用于要求可靠传输的应用, 例如文件传输。
  2. 面向无连接协议(UDP协议):不要求建立和断开连接。发送端可于任何时候自由发送数据。如同去寄信, 不需要确认收件人信息是否真实存在,也不需要确认收件人是否能收到信件,只要有个寄件地址就可以寄信了。U D P 是一种面向无连接的传输层协议,不会对自己提供的连接实施控制。适用于实时应用, 例如: I P 电话、视频会议、直播等

网络模型

从计算机网络层面来说,常见网络模型主要有OSI 参考模型和TCP/IP 模型两种,主要表达如下:

OSI 参考模型:

O S I 参考模型将网络协议提供的服务分成 7 层,并定义每一层的服务内容, 实现每一层服务的是协议, 协议的具体内容是规则。上下层之间通过接口进行交互,同一层之间通过协议进行交互。 O S I 参考模型只对各层的服务做了粗略的界定, 并没有对协议进行详细的定义,但是许多协议都对应了 7 个分层的某一层。所以要了解网络,首先要了解 O S I 参考模型:

  1. 应用层:O S I 参考模型的第 7 层( 最高层)。应用程序和网络之间的接口, 直接向用户提供服务。应用层协议有电子邮件、远程登录等协议。
  2. 表示层:O S I 参考模型的第 6 层。负责数据格式的互相转换, 如编码、数据格式转换和加密解密等。保证一个系统应用层发出的信息可被另一系统的应用层读出。
  3. 会话层:O S I 参考模型的第 5 层。主要是管理和协调不同主机上各种进程之间的通信(对话),即负责建立、管理和终止应用程序之间的会话。
  4. 传输层:O S I 参考模型的第 4 层。为上层协议提供通信主机间的可靠和透明的数据传输服务, 包括处理差错控制和流量控制等问题。只在通信主机上处理, 不需要在路由器上处理。
  5. 网络层:O S I 参考模型的第 3 层。在网络上将数据传输到目的地址, 主要负责寻址和路由选择。
  6. 数据链路层:O S I 参考模型的第 2 层。负责物理层面上两个互连主机间的通信传输, 将由 0、 1 组成的比特流划分成数据帧传输给对端,即数据帧的生成与接收。通信传输实际上是通过物理的传输介质实现的。 数据链路层的作用就是在这些通过传输介质互连的设备之间进行数据处理。网络层与数据链路层都是基于目标地址将数据发送给接收端的,但是网络层负责将整个数据发送给最终目标地址, 而数据链路层则只负责送一个分段内的数据。
  7. 物理层:O S I 参考模型的第 1 层( 最底层)。负责逻辑信号( 比特流) 与物理信号(电信号、光信号)之间的互相转换,通过传输介质为数据链路层提供物理连接。
TCP/IP 模型:

由于 OSI 参考模型把服务划得过于琐碎,先定义参考模型再定义协议,有点理想化。 TCP / IP 模型则正好相反, 通过已有的协议归纳总结出来的模型,成为业界的实际网络协议标准。TCP / IP 是有由 I E T F 建议、推进其标准化的一种协议, 是 IP 、 TCP 、HTTP 等协议的集合。TCP / IP是为使用互联网而开发制定的协议族, 所以互联网的协议就是 TCP / IP。TCP / IP 每层的主要协
议详情如下:

  1. 网络接入层:TCP / IP 是以 O S I 参考模型的物理层和数据链路层的功能是透明的为前提制定的,并未对这两层进行定义,所以可以把物理层和数据链路层合并称为网络接入层。网络接入层是对网络介质的管理,定义如何使用网络来传送数据。但是在通信过程中这两层起到的作用不一样, 所以也有把物理层和数据链路层分别称为硬件、网络接口层。 TCP / IP分为四层或者五层都可以,只要能理解其中的原理即可。设备之间通过物理的传输介质互连, 而互连的设备之间使用 M A C 地址实现数据传输。采用 M A C 地址,目的是为了识别连接到同一个传输介质上的设备。
  2. 网络层:相当于 OSI 模型中的第 3 层网络层, 使用 I P 协议。 I P 协议基于 I P 地址转发分包数据,作用是将数据包从源地址发送到目的地址。TCP / IP 分层中的网络层与传输层的功能通常由操作系统提供。 路由器就是通过网络层实现转发数据包的功能。
  3. 传输层:相当于 OSI 模型中的第 4 层传输层, 主要功能就是让应用程序之间互相通信,通过端口号识别应用程序, 使用的协议有面向连接的 TCP 协议和面向无连接的 UDP 协议。
  4. 应用层:相当于 OSI 模型中的第 5 - 7 层的集合, 不仅要实现 O S I 模型应用层的功能,还要实现会话层和表示层的功能。 HTTP 、 POP3 、 TELNET 、 SSH、 F T P 、 SNMP 都是应用层协议。

除此之外,我们还需要知道Linux 网络I/O 模型和Java JDK中的I/O 模型:

Linux 网络I/O 模型:

Linux的内核将所的外部设备看作一个文件来操作,对于一个文件的读写操作会调用内核提供的系统命令,返回一个文件描述符(fd,File Descriptor);同时,在面对一个Socket的读写时也会有相应的套接字描述符(socketfd,Socket File Descriptor),描述符是一个数字,它指向内核中的一个结构体,比如文件路径,数据区等。Linux 网络I/O 模型是按照UNIX网络编程来定义的,主要有:

阻塞I/O模型(Blocking I/O ):

最流行的I/O模型,本书到目前为止的所有例子都使用该模型。默认情形下,所有套接字都是阻塞的。使用UDP而不是TCP为例子的原因在于就UDP而言,数据准备好读取的概念比较简单:要么整个数据报已经收到,要么还没有。对于TCP而言,诸如套接字低水位标记等额外变量开始起作用,道指这个概念复杂。我们把recvfrom函数视为系统调用,因为我们正在区分应用进程和内核。不管如何实现,一般都会从在应用进程空间中国运行切换到在内核空间中运行,一端时间之后再切换回来。 在上图中,进程调用recvfrom,其系统调用直到数据报到达且被复制到应用进程的缓冲区中或者发送错误才返回。最常见的错误是系统调用被信号中断,我们说进程在从调用recvfrom开始到它返回的整段时间内是被阻塞的。recvfrom成功返回后,应用进程开始处理数据报。

非阻塞I/O模型(NoneBlocking I/O):

进程把一个套接字设置成非阻塞是在通知内核:当所有请求的I/O操作非得把本进程投入睡眠才能完成时,不要把本进程投入睡眠,而是返回一个错误。前三次调用recvfrom时没有数据可返回,因此内核转而立即返回一个EWOULDBLOCK错误。第四次调用recvfrom时已有一个数据报准备好,它被复制到应用进程缓冲区,于是recvfrom成功返回。接着处理数据。当一个应用进程像这样对一个非阻塞描述符循环调用recvfrom时,我们成为轮询,应用进程持续轮询内核,以查看某个操作是否就绪。这么做往往耗费大量CPU时间,不过这种模型偶尔也会遇到。

I/O复用模型(IO Multiplexing):

I/O复用,我们就可以调用select或者poll,阻塞在这两个系统调用中的某一个,而不是阻塞在真正的I/O系统调用上。我们阻塞与select调用,等待数据报套接字变为可读。当select返回套接字可读这一条件时,我们调用recvfrom把所可读数据报复制到应用进程缓冲区。比较上面两图,I/O复用并不显得有什么优势,事实上由于使用select需要两个而不是单个系统调用,其优势在于可以等待多个描述符就绪。

信号驱动I/O复用模型(Signal Driven IO):

可以用信号,让内核在描述符就绪时发送SIGIO信号通知我们。称为信号驱动式I/O。我们首先开启套接字的信号驱动式I/O功能,并通过sigaction系统调用安装一个信号处理函数。该系统调用将立即返回,我们的进程继续工作,也就是说它没有被阻塞。当数据报准备好读取时,内核就为该进程产生一个SIGIO信号。我们随后既可以在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已准备好待处理。也可以立即通知循环,让它读取数据报。无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间进程不被阻塞。主循环可以继续执行,只要等待来自信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据报已准备好被读取。

异步I/O模型(Asynchronous IO ):

告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。这种模型与前一节介绍的信号驱动模型的主要区别在于:信号驱动I/O是由内核通知我们如何启动一个I/O操作,而异步I/O模型是由内核通知我们I/O操作何时完成。我们调用aio_read函数,给内核传递描述符、缓冲区指针。缓冲区大小和文件偏移,并告诉内核当整个操作完成时如何通知我们。该系统调用立即返回,而且在等到I/O完成期间,我们的进程不被阻塞。

Java JDK中的I/O 模型:

在Java语言中,应用程序发起 I/O 调用后,会经历两个阶段:

  • 内核等待 I/O 设备准备好数据;
  • 内核将数据从内核空间拷贝到用户空间。

其中,阻塞和非阻塞:

  • 阻塞调用会一直等待远程数据就绪再返回,即上面的阶段1会阻塞调用者,直到读取结束;
  • 而非阻塞无论在什么情况下都会立即返回,虽然非阻塞大部分时间不会被block,但是它仍要求进程不断地去主动询问kernel是否准备好数据,也需要进程主动地再次调用recvfrom来将数据拷贝到用户内存。

而我们常说的同步和异步主要如下:

  • 同步方法会一直阻塞进程,直到I/O操作结束,注意这里相当于上面的阶段1,阶段2都会阻塞调用者。其中BIO,NIO,IO多路复用,信号驱动IO,这四种IO都可以归类为同步IO;
  • 而异步方法不会阻塞调用者进程,即使是从内核空间的缓冲区将数据拷贝到进程中这一操作也不会阻塞进程,拷贝完毕后内核会通知进程数据拷贝结束。
BIO模型

同步阻塞 IO 模型中,服务器应用程序发起 read 系统调用后,会一直阻塞,直到内核把数据拷贝到用户空间。完整的架构应该是 客户端-内核-服务器,客户端发起IO请求,服务器发起系统调用,内核把IO数据从内核空间拷贝到用户空间,服务器应用程序才能使用到客户端发送的数据。一般来说,客户端、服务端其实都属于用户空间,借助内核交流数据。

当用户进程发起了read系统调用,kernel就开始了IO的第一个阶段:准备数据。对于网络IO来说,很多时候数据在一开始还没有到达内核(比如说客户端目前只是建立了连接,还没有发送数据 或者是 网卡等待接收数据),所以kernel就需要要等待足够的数据到来。而在服务器进程这边,整个进程会被阻塞。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除阻塞状态,重新运行起来。

Java中的JDBC也使用到了BIO技术。BIO在客户端连接数量不高的情况下是没问题的,但是当面对十万甚至百万级连接的时候,无法处理这种高并发情况,因此我们需要一种更高效的 I/O 处理模型来应对。

NIO模型
file

Java 中的 NIO 于 JDK 1.4 中引入,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它支持面向缓冲的,基于通道的 I/O 操作方法。 对于高负载、高并发的(网络)情况下,应使用 NIO 。

当服务器进程发出read操作时,如果kernel中数据还没准备好,那么并不会阻塞服务器进程,而是立即返回error,用户进程判断结果是error,就知道数据还没准备好,此时用户进程可以去干其他的事情。一段时间后用户进程再次发read,一直轮询直到kernel中数据准备好,此时用户发起read操作,产生system call,kernel 马上将数据拷贝到用户内存,然后返回,进程就能使用到用户空间中的数据了。

BIO一个线程只能处理一个IO流事件,想处理下一个必须等到当前IO流事件处理完毕。而NIO其实也只能串行化的处理IO事件,只不过它可以在内核等待数据准备数据时做其他的工作,不像BIO要一直阻塞住。NIO它会一直轮询操作系统,不断询问内核是否准备完毕。但是,NIO这样又引入了新的问题,如果当某个时间段里没有任何客户端IO事件产生时,服务器进程还在不断轮询,占用着CPU资源。所以要解决该问题,避免不必要的轮询,而且当无IO事件时,最好阻塞住(线程阻塞住就会释放CPU资源了)。所以NIO引入了多路复用机制,可以构建多路复用的、同步非阻塞的IO程序。

AIO模型
file

AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。异步 IO 是基于事件和回调机制实现的,也就是进程操作之后会直接返回,不会阻塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。用户进程发起read操作之后,立刻就可以开始去做其它的事。

内核收到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何阻塞。kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

IO多路复用模型

Java 中的 NIO ,提供了 Selector(选择器)这个封装了操作系统IO多路复用能力的工具,通过Selector.select(),我们可以阻塞等待多个Channel(通道),知道任意一个Channel变得可读、可写,如此就能实现单线程管理多个Channels(客户端)。当所有Socket都空闲时,会把当前线程(选择器所处线程)阻塞掉,当有一个或多个Socket有I/O事件发生时,线程就从阻塞态醒来,并返回给服务端工作线程所有就绪的socket(文件描述符)。各个操作系统实现方案:

  • linux:select、poll、epoll
  • MacOS/FreeBSD:kqueue
  • Windows/Solaris:IOCP

IO多路复用题同非阻塞IO本质一样,只不过利用了新的select系统调用,由内核来负责本来是服务器进程该做的轮询操作。看似比非阻塞IO还多了一个系统调用的开销,不过因为可以支持多路复用IO,即一个进程监听多个socket,才算提高了效率。进程先是阻塞在select/poll上(进程是因为select/poll/epoll函数调用而阻塞,不是直接被IO阻塞的),再是阻塞在读写操作的第二阶段上(等待数据从内核空间拷贝到用户空间)。

IO多路复用的实现原理:利用select、poll、epoll可以同时监听多个socket的I/O事件的能力,而当有I/O事件产生时会被注册到Selector中。在所有socket空闲时,会把当前选择器进程阻塞掉,当有一个或多个流有I/O事件(或者说 一个或多个流有数据到达)时,选择器进程就从阻塞态中唤醒。通过select或poll轮询所负责的所有socket(epoll是只轮询那些真正产生了事件的socket),返回fd文件描述符集合给主线程串行执行事件。

⚠️[特别注意]:

select和poll每次调用时都需要将fd_set(文件描述符集合)从用户空间拷贝到内核空间中,函数返回时又要拷贝回来(epoll使用mmap,避免了每次wait都要将数组进行拷贝)。

在实际开发过程中,基于消息进行系统间通信,我们一般会有四种方法实现:

基于TCP/IP+BIO实现:

在Java中可基于Socket、ServerSocket来实现TCP/IP+BIO的系统通信。

  • Socket主要用于实现建立连接即网络IO的操作
  • ServerSocket主要用于实现服务器端口的监听即Socket对象的获取

为了满足服务端可以同时接受多个请求,最简单的方法是生成多个Socket。但这样会产生两个问题:

  • 生成太对Socket会消耗过多资源
  • 频繁创建Socket会导致系统性能的不足

为了解决上面的问题,通常采用连接池的方式来维护Socket。一方面能限制Socket的个数;另一方面避免重复创建Socket带来的性能下降问题。这里有一个问题就是设置合适的相应超时时间。因为连接池中Socket个数是有限的,肯定会造成激烈的竞争和等待。

Server服务端:

//创建对本地端口的监听
PrintWriter out = new PrintWriter(socket.getOutputStream(),true);
//向服务器发送字符串信息
out.println("hello");
//阻塞读取服务端的返回信息
in.readLine();

Client客户端:

//创建连接
Socket socket = new Socket(目标IP或域名, 目标端口);
//BufferedReader用于读取服务端返回的数据
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
//PrintWriter向服务器写入流
PrintWriter out = new PrintWriter(socket.getOutputStream(),true);
//像服务端发送流
out.println("hello");
//阻塞读取服务端的返回信息
in.readLine();
基于TCP/IP+NIO实现:

Java可以基于Clannel和Selector的相关类来实现TCP/IP+NIO方式的系统间通信。Channel有SocketClannel和ServerSocketChannel两种:

  • SocketClannel: 用于建立连接、监听事件及操作读写。
  • ServerSocketClannel: 用于监听端口即监听连接事件。
  • Selecter: 获取是否有要处理的事件。

Server服务端:

SocketChannel channel = SocketChannel.open();
//设置为非阻塞模式
channel.configureBlocking(false);
//对于非阻塞模式,立即返回false,表示连接正在建立中
channel.connect(SocketAdress);
Selector selector = Selector.open();
//向channel注册selector以及感兴趣的连接事件
channel.regester(selector,SelectionKey.OP_CONNECT);
//阻塞至有感兴趣的IO事件发生,或到达超时时间
int nKeys = selector.select(超时时间【毫秒计】);
//如果希望一直等待知道有感兴趣的事件发生
//int nKeys = selector.select();
//如果希望不阻塞直接返回当前是否有感兴趣的事件发生
//int nKeys = selector.selectNow();

//如果有感兴趣的事件
SelectionKey sKey = null;
if(nKeys>0){
    Set keys = selector.selectedKeys();
    for(SelectionKey key:keys){
        //对于发生连接的事件
        if(key.isConnectable()){
            SocketChannel sc = (SocketChannel)key.channel();
            sc.configureBlocking(false);
            //注册感兴趣的IO读事件
            sKey = sc.register(selector,SelectionKey.OP_READ);
            //完成连接的建立
            sc.finishConnect();
        }
        //有流可读取
        else if(key.isReadable()){
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            SocketChannel sc = (SocketChannel) key.channel();
            int readBytes = 0;
            try{
                int ret = 0;
                try{
                    //读取目前可读取的值,此步为阻塞操作
                    while((ret=sc.read(buffer))>0){
                        readBytes += ret;
                    }
                }
                fanally{
                    buffer.flip();
                }
             }
             finally{
                 if(buffer!=null){
                        buffer.clear();
                 }
             }
        }
        //可写入流
        else if(key.isWritable()){
            //取消对OP_WRITE事件的注册
            key.interestOps(key.interestOps() & (!SelectionKey.OP_WRITE));
            SocketChannel sc = (SocketChannel) key.channel();
            //此步为阻塞操作
            int writtenedSize = sc.write(ByteBuffer);
            //如未写入,则继续注册感兴趣的OP_WRITE事件
            if(writtenedSize==0){
                key.interestOps(key.interestOps()|SelectionKey.OP_WRITE);
            }
        }
    }
    Selector.selectedKeys().clear();
}
//对于要写入的流,可直接调用channel.write来完成。只有在未写入成功时才要注册OP_WRITE事件
int wSize = channel.write(ByteBuffer);
if(wSize == 0){
    key.interestOps(key.interestOps() | SelectionKey.OP_WRITE);
}

Server端实体:

ServerSocketChannel ssc = ServerSocketChannel.open();
ServerSocket serverSocket = ssc.socket();
//绑定要监听的接口
serverSocket.bind(new InetSocketAdress(port));
ssc.configureBlocking(false);
//注册感兴趣的连接建立事件
ssc.register(selector,SelectionKey.OP_ACCEPT);
基于UDP/IP+BIO实现:

Java对UDP/IP方式的网络数据传输同样采用Socket机制,只是UDP/IP下的Socket没有建立连接,因此无法双向通信。如果需要双向通信,必须两端都生成UDP Server。
Java中通过DatagramSocket和DatagramPacket来实现UDP/IP+BIO方式和系统间通信:

  • DatagramSocket:负责监听端口和读写数据

  • DatagramPacket:作为数据流对象进行传输
    由于UDP双端不建立连接,所以也就不存在竞争问题,只是最终读写流的动作是同步的。
//如果希望双向通信,必须启动一个监听端口承担服务器的职责
//如果不能绑定到指定端口,则抛出SocketException
DatagramSocket serverSocket = new DatagramSocket(监听的端口);
byte[] buffer = new byte[65507];
DatagramPacket receivePacket = new DatagramPacket(buffer,buffer.length);
DatagramSocket socket = new DatagramSocket();
DatagramPacket packet = new DatagramPacket(datas,datas.length,server.length);
//阻塞方式发送packet到指定的服务器和端口
socket.send(packet);
//阻塞并同步读取流消息,如果读取的流消息比packet长,则删除更长的消息
//当连接不上目标地址和端口时,抛出PortUnreachableException
DatagramSocket.setSoTimeout(超时时间--毫秒级);
serverSocket.receive(receivePacket);
基于UDP/IP+NIO实现:

Java中可以通过DatagramClannel和ByteBuffer来实现UDP/IP方式的系统间通信:

  • DatagramClannel:负责监听端口及进行读写
  • ByteBuffer:用于数据传输
//读取流信息
DatagramChannel receiveChannel = DatagramChannel.open();
receiveChannel.configureBlocking(false);
DatagramSocket socket = receiveChannel.socket();
socket.bind(new InetSocketAddress(rport));
Selector selector = Selector.open();
receiveChannel.register(selector, SelectionKey.OP_REEAD);
//之后即可像TCP/IP+NIO中对selector遍历一样的方式进行流信息的读取
//...
//写入流信息
DatagramChannel sendChannel = DatagramChannel.open();
sendChannel.configureBlocking(false);
SocketAdress target = new InetSocketAdress("127.0.0.1",sport);
sendChannel.connect(target);
//阻塞写入流
sendChannel.write(ByteBuffer);

发展历程

从软件系统的发展历程来看,在分布式应用出现之前,市面上几乎所有的软件系统都是集中式的,软件,硬件以及各个组件之间的高度耦合组成了单体架构软件平台,即就是所谓的单机系统。

一般来说,大型应用系统通常会被拆分成多个子系统,这些子系统可能会部署在多台机器上,也有可能只在一台机器上的多个线程中,这就是我们常说的分布式应用。

从部署形态上来说,以多台服务器和多个进程部署服务,都是为了实现一个业务需求和程序功能。分布式系统中的网络通信一般都会采用四层的 TCP 协议或七层的 HTTP 协议,在我的了解中,前者占大多数,这主要得益于 TCP 协议的稳定性和高效性。网络通信说起来简单,但实际上是一个非常复杂的过程,这个过程主要包括:对端节点的查找、网络连接的建立、传输数据的编码解码以及网络连接的管理等等,每一项都很复杂。

对于系统间通信来说,我们需要区分集群和分布式两个标准:

  • 分布式应用:一个业务拆分成多个子业务不熟在不同的服务器
  • 集群:同一个业务部署在不同的多台服务器上

实现方式

在分布式服务诞生以前,主要采用以下几种方式实现系统间的通信:

  1. Socket通信,基于TCP/UDP二进制通讯;效率最高,编程最复杂,需要自定义通讯格式;
  2. JavaEE体系中的RMI或EJB,在Socket基础之上封装的实现,直接面象Java对象编程,编程相对简单,不需要考虑低层实现,效率也不错,但只能是Java系统间通信
  3. 基于HTTP的通信,即服务端提供可访问URL,客户端模拟http请求完成通信;可跨平台跨语言,通讯效率相对较低,编程较简单。http+json。很多项目中应用。但是如果服务越来越多,服务与服务之间的调用关系复杂,调用URL管理复杂,什么时候添加机器难以确定。
  4. 基于Hessian,Remoting on HTTP,类似于RMI与Socket的关系;
  5. 基于JMS,异步通信等。
  6. 基于WebService,可跨平台跨语言,工具丰富,复杂通信相对编程简单,通信效率低。它是基于SOAP协议(http+xml:需要在一个工程中将数据变为xml格式,再传输到另外一个项目,并且xml传输数据过于臃肿)。项目中不推荐使用。

在分布式应用时代,业界通常一般两种方式可以来实现系统间的通信,主要如下:

  • 基于远程过程调用的方式(Remote Procedure Call):RPC服务调用,客户端不需要知道调用具体的实现细节,只用直接调用实际存在于远程计算机上的某个对象即可,调用方式就像调用本地应用程序的对象一样。使用dubbo。使用rpc协议进行远程调用,直接使用scoket通信(底层实现,使用二进制的流,所以效率高)。传输效率高,并且可以统计出系统之间的调用关系、调用次数,管理服务。
  • 基于消息队列的方式(Message Queue):MQ服务是某个系统负责发送消息,对于关心这条消息的系统负责接收消息,并且在接收到消息之后转给其他系统业务处理。

同时,从各系统间通信的整合方式,可以分为:

  • ESB方式:有服务顺序编排/定义,服务实现隔离、多协议支撑、协议翻译、转发代理、事务控制等功能
  • 服务注册中心(很多产品用zookeeper实现):和ESB最大的不同点是:“服务注册中心”主要提供各原子系统的服务注册、服务治理、服务隔离、权限控制。当客户端进行请求时,“服务治理”将告诉客户端到哪里去访问真实的服务,自己并不提供服务的转发。Dubbo就是一个典型的服务治理框架。

RPC服务调用(RPC服务)

RPC是一种通过网络从远程计算机程序上请求服务,不需要我们了解底层网络技术的协议。主要体现在以下几个方面:

  1. RPC是一种协议,也是一种规范所有的应用需要遵循这套规范实现。典型的RPC实现主要有Dubbo,Thrift,GRPC等。
  2. RPC通信对于网络来说是透明的,调用方不用关注网络之间的通信协议,网络I/O模型,以及通信的信息格式。
  3. RPC调用来说,是可以跨语言的,而且调用方不用关心服务端使用的是何种语言。
file

在 RPC 框架里面,我们是怎么支持插件化架构的呢?我们可以将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离,并提供接口的默认实现。在 Java 里面,JDK 有自带的 SPI(Service Provider Interface)服务发现机制,它可以动态地为某个接口寻找服务实现。使用 SPI 机制需要在 Classpath 下的 META-INF/services 目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体实现类。

但在实际项目中,我们其实很少使用到 JDK 自带的 SPI 机制,首先它不能按需加载,ServiceLoader 加载某个接口实现类的时候,会遍历全部获取,也就是接口的实现类得全部载入并实例化一遍,会造成不必要的浪费。另外就是扩展如果依赖其它的扩展,那就做不到自动注入和装配,这就很难和其他框架集成,比如扩展里面依赖了一个 Spring Bean,原生的 Java SPI 就不支持。

我们将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离并提供接口的默认实现。这样的架构相比之前的架构,有很多优势。首先它的可扩展性很好,实现了开闭原则,用户可以非常方便地通过插件扩展实现自己的功能,而且不需要修改核心功能的本身;其次就是保持了核心包的精简,依赖外部包少,这样可以有效减少开发人员引入 RPC 导致的包版本冲突问题。

一般一个RPC 框架里面都有会涉及两个模块:

  • 传输模块:RPC 本质上就是一个远程调用,那肯定就需要通过网络来传输数据。虽然传输协议可以有多种选择,但考虑到可靠性的话,我们一般默认采用 TCP 协议。为了屏蔽网络传输的复杂性,我们需要封装一个单独的数据传输模块用来收发二进制数据。
  • 协议封装:用户请求的时候是基于方法调用,方法出入参数都是对象数据,对象是肯定没法直接在网络中传输的,我们需要提前把它转成可传输的二进制,这就是我们说的序列化过程。但只是把方法调用参数的二进制数据传输到服务提供方是不够的,我们需要在方法调用参数的二进制数据后面增加“断句”符号来分隔出不同的请求,在两个“断句”符号中间放的内容就是我们请求的二进制数据。

除此之外,我们还可以在协议模块中加入压缩功能,这是因为压缩过程也是对传输的二进制数据进行操作。在实际的网络传输过程中,我们的请求数据包在数据链路层可能会因为太大而被拆分成多个数据包进行传输,为了减少被拆分的次数,从而导致整个传输过程时间太长的问题,我们可以在 RPC 调用的时候这样操作:在方法调用参数或者返回值的二进制数据大于某个阈值的情况下,我们可以通过压缩框架进行无损压缩,然后在另外一端也用同样的压缩算法进行解压,保证数据可还原。

传输和协议这两个模块是 RPC 里面最基础的功能,它们使对象可以正确地传输到服务提供方。但距离 RPC 的目标——实现像调用本地一样地调用远程,还缺少点东西。因为这两个模块所提供的都是一些基础能力,要让这两个模块同时工作的话,我们需要手写一些黏合的代码,但这些代码对我们使用 RPC 的研发人员来说是没有意义的,而且属于一个重复的工作,会导致使用过程的体验非常不友好。

消息队列(MQ服务)

分布式子系统之间需要通信时,就发送消息。一般通信的两个要点是:消息处理和消息传输。

  • 消息处理:例如读取数据和写入数据。基于消息方式实现系统通信的消息处理可以分为同步消息和异步消息。同步消息一般采用的是BIO(Blocking IO)和NIO(Non-Blocking IO);异步消息一般采用AIO方式。
  • 消息传输:消息传输需要借助网络协议来实现,TCP/IP协议和UDP/IP协议可以用来完成消息传输。

消息队列本质上是一种系统间相互协作的通信机制。一般使用消息队列可以业务解耦,流量削峰,日志收集,事务最终一致性,异步处理等业务场景。在我们实际开发工作中,一般消息队列的使用需要实现:

  • 消息处理中心(Message Broker):负责消息的接收,存储,转发等。
  • 消息生产者(Message Producer):负责产生和发送消息的消息处理中心。
  • 消息消费者(Message Consumber):负责从消息处理中心获取消息,并进行相应的处理。

当然,在技术选型的时候,我们需要选择最适合我们的。

版权声明:本文为博主原创文章,遵循相关版权协议,如若转载或者分享请附上原文出处链接和链接来源。

你可能感兴趣的:(消息中间件解析 | 如何正确理解软件应用系统中关于系统通信的那些事?)