概念
RPC(Remote Procedure Call) ,远程过程调用,是一种跨进程的、分布式的交互/通信形式。
随着互联网的发展,服务也逐渐从单机走向分布式,这就势必产生了分布式远程调用的需求。远程调用的需求产生是很自然的:大型软件不同的模块可以由不同的独立的团队进行开发,当我们需要另外团队的服务时,就可以通过调用相应的服务进行处理。
设想一个场景,假如我们需要调用另外一台机器上的服务,很自然的这种想法:将我的调用请求直接通过网络,发送到目标的机器上,目标机器处理完成之后直接返回给我。那么,再具体实现的时候,就有很多种想法,例如:我们可以直接自己搞一个简单的协议,将报文直接封装在TCP/UDP的报文中,来实现远程的通信和服务。
那么,RPC的初始想法就是这样,通过一些手段来进行网络通信,并实现远程的调用其他服务/方法/过程。定义是:
在分布式系统中,使用RPC调用远程服务和调用本地服务一样,即代码调用基本相同,RPC协议屏蔽了调用的底层细节。
两台服务器A,B,一个应用部署在A服务器上,想要调用B服务器上应用提供的函数/方法,由于不在一个内存空间,不能直接调用,需要通过网络来表达调用的语义和传达调用的数据。
举例
本例学习自b站马士兵:30行代码透彻解析RPC
项目初始
首先,设想这样一个项目:一个User类,记录了用户的id和姓名,对外提供了一个接口,用于查询id对应的用户。代码如下:
/**
用户类
存放用户id和姓名
*/
@Data
public class User{
int id;
String name;
}
对外提供一个查询id对应用户的接口:
/**
服务接口
*/
public interface IUserService {
//查询id对应用户
public User findUserById(Integer id);
}
现在,这个服务是用户管理部门写的,实现在了机器A上,我们业务部门想要在机器B上,调用用户查询接口,最简单的方式就是采用TCP方式,使用现成的Socket通信,将我们的调用需求发送出去:
public class Client {
public static void main(String[] args) throws IOException {
// 初始化socket,发送到对方8888端口
Socket s = new Socket("127.0.0.1", 8888);
// 创建输出流
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(baos);
//客户端将消息写出去
dos.writeInt(123);// 将123转化为二进制格式
s.getOutputStream().write(baos.toByteArray());
s.getOutputStream().flush();
// 读取服务端的消息
DataInputStream dis = new DataInputStream(s.getInputStream());
// 客户端接收消息,和服务端商量好,先传id再传name
int id = dis.readInt();
String name=dis.readUTF();
User user = new User(id, name);
System.out.println(user);
// 资源关闭
dos.close();
s.close();
}
}
那么服务端需要对来的报文进行处理:
public class Server {
private static boolean running=true;
public static void main(String[] args) throws IOException {
// 服务端监听8888端口
ServerSocket ss = new ServerSocket(8888);
while(running){
Socket s = ss.accept();
process(s);
s.close();
}
ss.close();
}
private static void process(Socket s) throws IOException {
// 输入流输出流
InputStream in = s.getInputStream();
OutputStream out = s.getOutputStream();
DataInputStream dis = new DataInputStream(in);
DataOutputStream dos = new DataOutputStream(out);
// 服务端接收消息
int id = dis.readInt();
UserServiceImpl service = new UserServiceImpl();
User user = service.findUserById(id);
//服务端向客户端发送消息
dos.writeInt(user.getId());
dos.writeUTF(user.getName());
dos.flush();
}
}
那么这样就实现了远程的一次调用。但是显然,这样做是很原始的:
- 我们部门的程序员需要了解一切的网络传输方面的细节
- 当代码需要进行修改时,例如用户管理部门提供了更多的字段,双方均需要对代码进行大量的修改
引入代理
针对上面提到的缺点,我们作为学过什么是封装的程序员,自然而然的会想到,将涉及网络的内容封装起来不就好了。因此,引入了Stub代理:
/**
Stub代理
将网络部分的内容封装起来
*/
public class Stub {
public User findUserById(Integer id) throws IOException {
Socket s = new Socket("127.0.0.1", 8888);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(baos);
dos.writeInt(123);
s.getOutputStream().write(baos.toByteArray());
s.getOutputStream().flush();
DataInputStream dis = new DataInputStream(s.getInputStream());
int receiveId = dis.readInt();
String name=dis.readUTF();
User user = new User(receiveId, name);
dos.close();
s.close();
return user;
}
}
这样,作为客户,我们就可以这样调用:
/**
引入Stub的客户端
只需要调用Stub的相应方法就可以实现远程调用了
*/
public class Client {
public static void main(String[] args) throws IOException {
Stub stub = new Stub();
System.out.println(stub.findUserById(123));
}
}
这样就成功的将网络部分的内容隐藏起来了,作为客户我们不需要了解网络那边的实现细节了,调用函数给我一个User就行了
但是,这样只能代理这一个方法,也就是findUserById(),但是不可能我一个业务项目只需要调用这一个函数。对着业务增多,随着业务增多,Stub中会堆积很多的函数。
引入动态代理
接下来,我们就想着继续解决问题:如何减少Stub类的压力。我们调用的时候不通过Stub类,而是通过IUserService接口去调用,这就需要引入动态代理:
/**
Stub代理
引入了动态代理
*/
public class Stub {
public static IUserService getStub(){
//对调用的方法进行泛化
InvocationHandler h=new InvocationHandler(){
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Socket s = new Socket("127.0.0.1", 8888);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(baos);
dos.writeInt(123);
s.getOutputStream().write(baos.toByteArray());
s.getOutputStream().flush();
DataInputStream dis = new DataInputStream(s.getInputStream());
int id = dis.readInt();
String name = dis.readUTF();
User user = new User(id, name);
dos.close();
s.close();
return user;
}
};
//动态代理对象需要和被代理对象实现同一个接口IUserService
Object o = Proxy.newProxyInstance(IUserService.class.getClassLoader(), new Class[]{IUserService.class}, h);
// 返回一个IUserService类
return (IUserService)o;
}
}
通过引入动态代理,外部使用返回的动态代理对象去调用某个功能时,内部就会调用invoke方法,在Stub中不用写每一个业务函数的细节。
但是这样,stub的参数还是写死的,总不能我下次想查什么参数,需要联系Stub的程序员,到Stub里面再去修改吧,需要进一步泛化才行。
客户端传递的参数优化处理
在我们实现的invoke()函数中,参数位分别为:
- Object proxy :被代理对象,这里是IUserService
- Method method :需要调用哪个函数,这里是findUserById()
- Object[] args :函数的参数:即函数的参数,这里参数列表暂时是空的
这样我们就可以灵活的处理网络中传输的参数,如下所示:
/**
Stub代理
灵活处理要调用的函数和参数
*/
public class Stub {
public static IUserService getStub(){
InvocationHandler h = new InvocationHandler() {
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Socket s = new Socket("127.0.0.1", 8888);
ObjectOutputStream oos = new ObjectOutputStream(s.getOutputStream());
String methodName = method.getName();
Class[] parameterTypes = method.getParameterTypes();
oos.writeUTF(methodName);//Stub向服务端发送调用方法的方法名
oos.writeObject(parameterTypes);//Stub向服务端发送调用方法的参数类型
oos.writeObject(args);//Stub向服务端发送调用方法的具体参数
oos.flush();
DataInputStream dis = new DataInputStream(s.getInputStream());
int id = dis.readInt();
String name = dis.readUTF();
User user = new User(id, name);
oos.close();
s.close();
return user;
}
};
//生成动态代理对象:它与被代理对象实现同一个接口,因此需要的参数有接口的类加载器、接口的类信息
Object o = Proxy.newProxyInstance(IUserService.class.getClassLoader(), new Class[]{IUserService.class}, h);
//返回动态代理对象
return (IUserService)o;
}
}
这样就可以更加灵活的发送我们的参数,调用远程的接口。
我们在客户端调用的时候,就可以这样调用:
/**
引入参数泛化处理的客户端
*/
public class Client {
public static void main(String[] args) {
IUserService service = (IUserService) Stub.getStub(IUserService.class);
System.out.println(service.findUserById(123));
}
}
由于修改了双方商量好的协议,服务端也需要进行修改:
/**
服务端
客户那边来的参数改变了,服务端也要改变接收方式
*/
public class Service {
private static boolean running=true;
public static void main(String[] args) throws IOException {
ServerSocket ss = new ServerSocket(8888);//服务端对接口进行监听
while(running){
Socket s = ss.accept();
}
}
private static void process(Socket s) throws IOException, ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
InputStream in = s.getInputStream();
OutputStream out = s.getOutputStream();
ObjectInputStream oos = new ObjectInputStream(in);
DataOutputStream dos = new DataOutputStream(out);
String methodName = oos.readUTF();//读取客户端调用的方法名
Class[] parameterTypes=(Class[])oos.readObject();//读取客户端调用的方法的参数类型
Object[] args=(Object[])oos.readObject();//读取客户端调用的方法的参数
IUserService service = new UserServiceImpl();
Method method = service.getClass().getMethod(methodName, parameterTypes);//获得对应的方法
User user = (User) method.invoke(service, args);// 调用对应的方法
dos.writeInt(user.getId());
dos.writeUTF(user.getName());
dos.flush();
}
}
这样,就没有硬编码的参数了,我们可以灵活的向服务端传递相关参数。
但是还是有个问题:服务端仍然不灵活。我们业务部门调用获取User的时候很爽了,但是我和用户管理部门还需要进行协商,商量他会给我返回什么值。在上面代码中,服务端必须先向网络流里面写id,再写name,我读取的时候也是先读id再读name,如果哪天user类进行了修改增加了一些字段,那么就必须双方都进行修改才行了。
服务端返回的参数优化处理
由于上面的痛点,我们双方决定,采用一些序列化方法,直接在网络报文中传输这个对象,至于对象有什么字段我客户端这边唆了嗦了味不就得了。
因此作为服务端,我要直接向网络流里面传输序列化的对象:
/**
服务端
把用户信息一点点写进网络流实在难以扩展,不如直接给你搞个对象
*/
public class Service {
private static boolean running=true;
public static void main(String[] args) throws IOException {
ServerSocket ss = new ServerSocket(8888);
while(running){
Socket s = ss.accept();
}
}
private static void process(Socket s) throws IOException, ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
InputStream in = s.getInputStream();
OutputStream out = s.getOutputStream();
ObjectInputStream ois = new ObjectInputStream(in);
String methodName = ois.readUTF();
Class[] parameterTypes=(Class[])ois.readObject();
Object[] args=(Object[])ois.readObject();
IUserService service = new UserServiceImpl();
Method method = service.getClass().getMethod(methodName, parameterTypes);
User user = (User) method.invoke(service, args);
ObjectOutputStream oos = new ObjectOutputStream(out);
oos.writeObject(user);//直接写对象一把嗦
oos.flush();
}
}
那么我作为客户端,要接的时候也是接对象了:
/**
Stub代理
服务端给的是对象了,接收也按照对象接
*/
public class Stub {
public static IUserService getStub(){
InvocationHandler h = new InvocationHandler() {
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Socket s = new Socket("127.0.0.1", 8888);
ObjectOutputStream oos = new ObjectOutputStream(s.getOutputStream());
String methodName = method.getName();
Class[] parameterTypes = method.getParameterTypes();
oos.writeUTF(methodName);
oos.writeObject(parameterTypes);
oos.flush();
ObjectInputStream ois = new ObjectInputStream(s.getInputStream());
User user = (User)ois.readObject();//直接从二进制流中读入一个User对象
oos.close();
s.close();
return user;
}
};
Object o = Proxy.newProxyInstance(IUserService.class.getClassLoader(), new Class[]{IUserService.class}, h);
return (IUserService)o;
}
}
这里的序列化方法是jdk自带的,可能不太符合业务的需求,可以换成任何序列化的方法,如Hessian序列化、JSON序列化等等。
到这一步,我们就能够实现远程、灵活地调用用户管理部门那边的函数了。
思考
其实,对于一个RPC服务,上述代码已经。已经粗略理解了RPC的关键职责:
- 让调用方感觉就像调用本地函数一样调用远端函数
- 让服务提供方感觉就像实现一个本地函数一样来实现服务
进一步的学习,将会继续更新。