在分布式服务框架中,一个最基础的问题就是远程服务是怎么通讯的,在Java领域中有很多可实现远程通讯的技 术,例如:RMI、Hessian、SOAP、ESB和JMS等,它们背后到底是基于什么原理实现的呢
1.基本原理
要实现网络机器间的通讯,首先得来看看计算机系统网络通信的基本原理,在底层层面去看,网络通信需要做的就是将流从一台计算机传输到另外一台计算机,基于传输协议和网络IO来实现,其中传输协议比较出名的有tcp、udp等等,tcp、udp都是在基于Socket概念上为某类应用场景而扩展出的传输协议,网络IO,主要有bio、nio、aio三种方式,所有的分布式应用通讯都基于这个原理而实现,只是为了应用的易用,各种语言通常都会提供一些 更为贴近应用易用的应用层协议。
- 网络数据传输的要素有哪些?
-
协议:
- UDP:广播协议,面向无连接,速度快,不安全
- TCP:面向连接的协议,速度不快,较为安全
-
IO:
- BIO: 同步阻塞IO
- NIO: 非阻塞IO
- AIO: 非阻塞IO
-
2.什么是RPC
RPC全称为remote procedure call,即远程过程调用
借助RPC可以做到像本地调用一样调用远程服务,是一种进程间的通信方式
比如两台服务器A和B,A服务器上部署一个应用,B服务器上部署一个应用,A服务器上的应用想调用B服务器上的应用提供的方法,由于两个应用不在一个内存空间,不能直接调用,所以需要通过网络来表达调用的语义和传达调用的数据。
需要注意的是RPC并不是一个具体的技术,而是指整个网络远程调用过程。
RPC架构
一个完整的RPC架构里面包含了四个核心的组件,分别是Client,Client Stub,Server以及Server Stub,这个Stub可以理解为存根。
- 客户端(Client),服务的调用方。
- 客户端存根(Client Stub),存放服务端的地址消息,再将客户端的请求参数打包成网络消息,然后通过网络远程发送给服务方。把对象进行序列化
- 服务端(Server),真正的服务提供者。
- 服务端存根(Server Stub)【服务端助手】,接收客户端发送过来的消息,将消息解包,并调用本地的方法
RPC调用过程
(1) 客户端(client)以本地调用方式(即以接口的方式)调用服务;
(2) 客户端存根(client stub)接收到调用后,负责将方法、参数等组装成能够进行网络传输的消息体(将消息体对象序列化为二进制);
(3) 客户端通过sockets将消息发送到服务端;
(4) 服务端存根( server stub)收到消息后进行解码(将消息对象反序列化);
(5) 服务端存根( server stub)根据解码结果调用本地的服务;
(6) 本地服务执行并将结果返回给服务端存根( server stub);
(7) 服务端存根( server stub)将返回结果打包成消息(将结果消息对象序列化);
(8) 服务端(server)通过sockets将消息发送到客户端;
(9) 客户端存根(client stub)接收到结果消息,并进行解码(将结果消息发序列化);
(10) 客户端(client)得到最终结果。
RPC的目标是要把2、3、4、7、8、9这些步骤都封装起来。
注意:无论是何种类型的数据,最终都需要转换成二进制流在网络上进行传输,数据的发送方需要将对象转换为二进制流,而数据的接收方则需要把二进制流再恢复为对象。
在java中RPC框架比较多,常见的有Hessian、gRPC、Thrift、阿里巴巴-HSF (High Speed Service Framework)、Dubbo等,其实对 于RPC框架而言,核心模块 就是通讯和序列化
3. RMI
简介
Java RMI 指的是远程方法调用 (Remote Method Invocation),是java原生支持的远程调用 ,采用JRMP(JavaRemote Messageing protocol)作为通信协议,可以认为是纯java版本的分布式远程调用解决方案, RMI主要用于不同虚拟机之间的通信,这些虚拟机可以在不同的主机上、也可以在同一个主机上,这里的通信可以理解为一个
虚拟机上的对象调用另一个虚拟机上对象的方法。
**1.客户端: **
-
1)存根/桩(Stub):远程对象在客户端上的代理; 【和远程方法是一样的】
-
2)远程引用层(Remote Reference Layer):解析并执行远程引用协议;本地对象转换成远程引用
-
3)传输层(Transport):发送调用、传递远程方法参数、接收远程方法执行结果。
2.服务端:
- 1)骨架(Skeleton):读取客户端传递的方法参数,调用服务器方的实际对象方法,并接收方法执行后的返回值;服务端的一个助手
- 2)远程引用层(Remote Reference Layer):处理远程引用后向骨架发送远程方法调用;把远程对象转成本地对象。
- 3)传输层(Transport):监听客户端的入站连接,接收并转发调用到远程引用层。
3.注册表(Registry):以URL形式注册远程对象,并向客户端回复对远程对象的引用。有点像zk
远程调用过程:
1)客户端从远程服务器的注册表中查询并获取远程对象引用。
2)桩对象与远程对象具有相同的接口和方法列表,当客户端调用远程对象时,实际上是由相应的桩对象代理完成的。
3 )远程引用层在将桩的本地引用转换为服务器上对象的远程引用后,再将调用传递给传输层(Transport),由传输层通 过TCP协议发送调用;
4)在服务器端,传输层监听入站连接,它一旦接收到客户端远程调用后,就将这个引用转发给其上层的远程引用层;
5)服务器端的远程引用层将客户端发送的远程应用转换为本地虚拟机的引用后,再将请求传递给骨架(Skeleton);
6)骨架读取参数,又将请求传递给服务器,最后由服务器进行实际的方法调用。
结果返回过程:
1)如果远程方法调用后有返回值,则服务器将这些结果又沿着“骨架->远程引用层->传输层”向下传递;
2)客户端的传输层接收到返回值后,又沿着“传输层->远程引用层->桩”向上传递,然后由桩来反序列化这些返回值,并 将最终的结果传递给客户端程序。
开发流程
1.服务端:
1)定义Remote子接口,在其内部定义要发布的远程方法,并且这些方法都要Throws RemoteException;
2)定义实现远程接口,并且继承:UnicastRemoteObject
3)启动服务器:依次完成注册表的启动和远程对象绑定。
2.客户端:
1)通过符合JRMP规范的URL字符串在注册表中获取并强转成Remote子接口对象;
2)调用这个Remote子接口对象中的某个方法就是为一次远程方法调用行为。
代码部分
1.服务端
1)定义Remote子接口,在其内部定义要发布的远程方法,并且这些方法都要Throws RemoteException;
public interface IHelloService extends Remote {
//1.定义一个sayHello方法
public String sayHello(User user) throws RemoteException;
}
2)定义实现远程接口,并且继承:UnicastRemoteObject
public class HelloServiceImpl extends UnicastRemoteObject implements IHelloService {
//手动实现父类的构造方法
public HelloServiceImpl() throws RemoteException {
super();
}
//我们自定义的sayHello
public String sayHello(User user) throws RemoteException {
System.out.println("this is server , say hello to "+user.getUsername());
return "success";
}
}
3)启动服务器:依次完成注册表的启动和远程对象绑定。
public class RMIServer {
public static void main(String[] args) throws RemoteException, AlreadyBoundException, MalformedURLException {
//1.创建HelloService实例
IHelloService service = new HelloServiceImpl();
//2.获取注册表
LocateRegistry.createRegistry(8888);
//3.对象的绑定
//bind方法的参数1: rmi://ip地址:端口/服务名 参数2:绑定的对象
Naming.bind("//127.0.0.1:8888/rmiserver",service);
}
}
- 传输的对象需要实现序列化接口
public class User implements Serializable {
private String username;
private int age;
}
2.客户端
- 创建一个Remote子接口对象,这个对象和远程服务接口一致
public interface IHelloService extends Remote {
//1.定义一个sayHello方法
public String sayHello(User user) throws RemoteException;
}
2)通过符合JRMP规范的URL字符串在注册表中获取并强转成Remote子接口对象;
public class RMIClient {
public static void main(String[] args) throws RemoteException, NotBoundException, MalformedURLException {
//1.从注册表中获取远程对象 , 强转
IHelloService service = (IHelloService) Naming.lookup("//127.0.0.1:8888/rmiserver");
//2.准备参数
User user = new User("laowang",18);
//3.调用远程方法sayHello
String message = service.sayHello(user);
System.out.println(message);
}
}
客户端演示结果
服务端演示结果
BIO、NIO、AIO
同步和异步
同步(synchronize)、异步(asychronize)是指应用程序和内核的交互而言的
同步:指用户进程触发IO操作等待或者轮训的方式查看IO操作是否就绪。
同步举例:
银行取钱,我自己去取钱,取钱的过程中等待
异步: 当一个异步进程调用发出之后,调用者不会立刻得到结果。而是在调用发出之后,被调用者通过状态、通知来通知调用者,或者通过回调函数来处理这个调用。
使用异步IO时,Java将IO读写委托给OS处理,需要将数据缓冲区地址和大小传给OS,OS需要支持异步IO操作
异步举例:
我请朋友帮我取钱,他取到钱后返回给我. (委托给操作系统OS, OS需要支持IO异步API)
阻塞和非阻塞
阻塞和非阻塞是针对于进程访问数据的时候,根据IO操作的就绪状态来采取不同的方式.
简单点说就是一种读写操作方法的实现方式. 阻塞方式下读取和写入将一直等待, 而非阻塞方式下,读取和写入方法会理解返回一个状态值
举个例子:
-
阻塞:
ATM机排队取款,你只能等待排队取款(使用阻塞IO的时候,Java调用会一直阻塞到读写完成才返回。)
-
非阻塞:
柜台取款,取个号,然后坐在椅子上做其他事,等广播通知,没到你的号你就不能去,但你可以不断的问大堂经理排到了没有。(使用非阻塞IO时,如果不能读写Java调用会马上返回,当IO事件分发器会通知可读写时再继续进行读写,不断循环直到读写完成)
老张煮开水。 老张,水壶两把(普通水壶,简称水壶;会响的水壶,简称响水壶)。
1 老张把水壶放到火上,站立着等水开。(同步阻塞)
2 老张把水壶放到火上,去客厅看电视,时不时去厨房看看水开没有。(同步非阻塞)
3 老张把响水壶放到火上,立等水开。(异步阻塞)
4 老张把响水壶放到火上,去客厅看电视,水壶响之前不再去看它了,响了再去拿壶。(异步非阻塞)
BIO
同步阻塞IO。B代表blocking
服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,当然可以通过线程池机制改善。
适用场景:Java1.4之前唯一的选择,简单易用但资源开销太高
BIO在处理并发请求的时候,效率特别的低,没有请求的时候,就一直阻塞
BIO 服务端代码
public class IOServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress("127.0.0.1",8081));
while (true){
//同步阻塞一个请求
Socket socket = serverSocket.accept();
byte[] bytes = new byte[1024];
//同步阻塞获取数据
int len = socket.getInputStream().read(bytes);
System.out.println(new String(bytes,0,len));
socket.getOutputStream().write(bytes,0,len);
socket.getOutputStream().flush();
}
}
}
BIO 客户端代码
public class IOClient {
public static void main(String[] args) throws IOException {
//开启一个socket,建立起一个连接
Socket socket = new Socket("127.0.0.1", 8081);
//往socket中的输出流,写入一个字符串
socket.getOutputStream().write("hello".getBytes());
//刷新socket中的输出流
socket.getOutputStream().flush();
System.out.println("server send back data===");
byte[] bytes = new byte[1024];
int len = socket.getInputStream().read(bytes);
System.out.println(new String(bytes,0,len));
socket.close();
}
}
NIO
1.NIO介绍
同步非阻塞IO (non-blocking IO / new io)是指JDK 1.4 及以上版本。
服务器实现模式为一个请求一个通道,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有IO请求时才启动一个线程进行处理。
通道(Channels) NIO 新引入的最重要的抽象是通道的概念。Channel 数据连接的通道。 数据可以从Channel读到Buffer中,也可以从Buffer 写到Channel中
缓冲区(Buffers) 通道channel可以向缓冲区Buffer中写数据,也可以像buffer中存数据
选择器(Selector) 使用选择器,借助单一线程,就可对数量庞大的活动 I/O 通道实时监控和维护。
SelecTor做的事情
-
关注客户端有没有连接客户端
-
监控channel有没有数据读写
2.特点
当一个连接创建后,不会需要对应一个线程,这个连接会被注册到多路复用器,所以一个连接只需要一个线程即可,所有的连接需要一个线程就可以操作,该线程的多路复用器会轮询,发现连接有请求时,才开启一个线程处理
如上图所示,IO模型中,一个连接来了,会创建一个线程,对应一个while死循环,死循环的目的就是不断监测这条连接上是否有数据可以读,大多数情况下,1w个连接里面同一时刻只有少量的连接有数据可读,因此,很多个while死循环都白白浪费掉了,因为读不出啥数据。
而在NIO模型中,他把这么多while死循环变成一个死循环,这个死循环由一个线程控制,那么他又是如何做到一个线程,一个while死循环就能监测1w个连接是否有数据可读的呢? 这就是NIO模型中selector的作用,一条连接来了之后,现在不创建一个while死循环去监听是否有数据可读了,而是直接把这条连接注册到selector上,然后,通过检查这个selector,就可以批量监测出有数据可读的连接,进而读取数据,下面我再举个非常简单的生活中的例子说明IO与NIO的区别
在一家幼儿园里,小朋友有上厕所的需求,小朋友都太小以至于你要问他要不要上厕所,他才会告诉你。幼儿园一共有100个小朋友,有两种方案可以解决小朋友上厕所的问题:
1. 每个小朋友配一个老师。每个老师隔段时间询问小朋友是否要上厕所,如果要上,就领他去厕所,100个小朋友就需要 100个老师来询问,并且每个小朋友上厕所的时候都需要一个老师领着他去上,这就是IO模型,一个连接对应一个线程。
2. 所有的小朋友都配同一个老师。这个老师隔段时间询问所有的小朋友是否有人要上厕所,然后每一时刻把所有要上厕所 的小朋友批量领到厕所,这就是NIO模型,所有小朋友都注册到同一个老师,对应的就是所有的连接都注册到一个线程,然后批量轮询。
AIO
异步非阻塞IO。A代表asynchronize
当有流可以读时,操作系统会将可以读的流传入read方法的缓冲区,并通知应用程序,对于写操作,OS将write方法的流 写入完毕是操作系统会主动通知应用程序。因此read和write都是异步 的,完成后会调用回调函数。
使用场景:连接数目多且连接比较长(重操作)的架构,比如相册服务器,导航。重点调用了OS参与并发操作,编程比 较复杂。Java7开始支持
Netty
1.Netty认识
Netty 是由 JBOSS 提供一个异步的、 基于事件驱动的网络编程框架
Netty 可以帮助你快速、 简单的开发出一 个网络应用, 相当于简化和流程化了 NIO 的开发过程。 作为当前最流行 的 NIO 框架, Netty 在互联网领域、 大数据分布式计算领域、 游戏行业、 通信行业等获得了广泛的应用, 知名 的 Elasticsearch 、 Dubbo 框架内部都采用了 Netty。
为什么使用Netty
NIO缺点
- NIO 的类库和 API 繁杂,使用麻烦。你需要熟练掌握 Selector、ServerSocketChannel、SocketChannel、ByteBuffer 等.
- 可靠性不强,开发工作量和难度都非常大
- NIO 的 Bug。例如 Epoll Bug,它会导致 Selector 空轮询,最终导致 CPU 100%。
Netty优点
- 对各种传输协议提供统一的 API
- 高度可定制的线程模型——单线程、一个或多个线程池
- 更好的吞吐量,更低的等待延迟
- 更少的资源消耗
- 最小化不必要的内存拷贝
2.线程模型
-
单线程模型
客户端与服务器建立起一个通道之后,就会注册到多路复用的selector中,select定时的轮询各个线程,看看通道的状态是否发生了变更,如果变更了,则统一进行处理,调用相应的Handler进行操作
-
线程池模型
左边单独开启一个线程用于处理用户连接的,右边则维护一个线程池,监听socketChannel,如果有任务到来,则选出 一个多路复用selector进行处理。
但是当用户的数量成百上千时,处理用户连接的线程又会成为瓶颈
- Netty 模型
Netty 抽象出两组线程池,
-
BossGroup 专门负责接收客户端连接,
-
WorkerGroup 专门负责网络读写操作。
-
NioEventLoop 表示一个不断循环执行处理任务的线程, 每个 NioEventLoop 都有一个 selector, 用于监听绑定在其上的 socket 网络通道。
- NioEventLoop 内部采用串行化设计, 从消息的读取->解码->处理->编码->发送, 始终由 IO 线 程 NioEventLoop 负责
Netty核心组件
ChannelHandler 及其实现类
ChannelHandler 接口定义了许多事件处理的方法, 我们可以通过重写这些方法去实现具 体的业务逻辑
我们经常需要自定义一个 Handler 类去继承 ChannelInboundHandlerAdapter, 然后通过 重写相应方法实现业 务逻辑, 我们接下来看看一般都需要重写哪些方法:
- public void channelActive(ChannelHandlerContext ctx), 通道就绪事件
- public void channelRead(ChannelHandlerContext ctx, Object msg), 通道读取数据事件
- public void channelReadComplete(ChannelHandlerContext ctx) , 数据读取完毕事件
- public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause), 通道发生异常事件
ChannelPipeline
ChannelPipeline 是一个 Handler 的集合, 它负责处理和拦截 inbound 或者 outbound 的事 件和操作, 相当于 一个贯穿 Netty 的链。
- ChannelPipeline addFirst(ChannelHandler... handlers), 把一个业务处理类(handler) 添加到链中的第一 个位置
- ChannelPipeline addLast(ChannelHandler... handlers), 把一个业务处理类(handler) 添加到链中的最后 一个位置
ChannelHandlerContext
这 是 事 件 处 理 器 上 下 文 对 象 , Pipeline 链 中 的 实 际 处 理 节 点 。 每 个 处 理 节 点 ChannelHandlerContext 中 包 含 一 个 具 体 的 事 件 处 理 器 ChannelHandler , 同 时 ChannelHandlerContext 中也绑定了对应的 pipeline 和 Channel 的信息,方便对 ChannelHandler 进行调用。 常用方法如下所示
- ChannelFuture close(), 关闭通道
- ChannelOutboundInvoker flush(), 刷新
- ChannelFuture writeAndFlush(Object msg) , 将 数 据 写 到 ChannelPipeline 中 当 前 【ChannelHandler处理完事情后,调用writeAndFlush方法,把上下文数据刷新到Pipeline中】
- ChannelHandler 的下一个 ChannelHandler 开始处理(出站)
ChannelFuture
表示 Channel 中异步 I/O 操作的结果, 在 Netty 中所有的 I/O 操作都是异步的, I/O 的调 用会直接返回, 调用者 并不能立刻获得结果, 但是可以通过 ChannelFuture 来获取 I/O 操作 的处理状态。 常用方法如下所示:
Channel channel(), 返回当前正在进行 IO 操作的通道
ChannelFuture sync(), 等待异步操作执行完毕
EventLoopGroup 和其实现类 NioEventLoopGroup
EventLoopGroup【线程池】 是一组 EventLoop 的抽象, Netty 为了更好的利用多核 CPU 资源, 一般 会有多个 EventLoop
同时工作, 每个 EventLoop 维护着一个 Selector 实例。 EventLoopGroup 提供 next 接口, 可以从组里面按照一 定规则获取其中一个 EventLoop 来处理任务。 在 Netty 服务器端编程中, 我们一般都需要提供两个
EventLoopGroup, 例如: BossEventLoopGroup【连接】 和 WorkerEventLoopGroup【读写】
- public NioEventLoopGroup(), 构造方法
- public Future<?> shutdownGracefully(), 断开连接, 关闭线程
ServerBootstrap 和 Bootstrap
ServerBootstrap是Netty中的服务器端启动助手,通过它可以完成服务器端的各种配置;
Bootstrap 是 Netty中的客户端启动助手, 通过它可以完成客户端的各种配置。 常用方法如下 所示:
- public ServerBootstrap group(EventLoopGroup parentGroup, EventLoopGroup childGroup),该方法用于 服务器端, 用来设置两个 EventLoop
- public B group(EventLoopGroup group) , 该方法用于客户端, 用来设置一个 EventLoop
- public B channel(Class<? extends C> channelClass), 该方法用来设置一个服务器端的通道实现 ,netty中通道有很多,NIO
- public <T> B option(ChannelOption<T> option, T value), 用来给 ServerChannel 添加配置
- public <T> ServerBootstrap childOption(ChannelOption<T> childOption, T value), 用来给接收到的 通道添加配置
- public ServerBootstrap childHandler(ChannelHandler childHandler), 该方法用来设置业务处理类(自定 义的 handler)
- public ChannelFuture bind(int inetPort) , 该方法用于服务器端, 用来设置占用的端口号
- public ChannelFuture connect(String inetHost, int inetPort) 该方法用于客户端, 用来连接服务器端
延伸资料: