远程过程调用RPC 1:举例理解

概念

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的关键职责:

  1. 让调用方感觉就像调用本地函数一样调用远端函数
  2. 让服务提供方感觉就像实现一个本地函数一样来实现服务

进一步的学习,将会继续更新。

参考

马士兵:30行代码透彻解析RPC
RPC基础
JavaGuide - rpc基础

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值