• 【专栏】RPC系列(实战)-优雅的序列化


    公众号【离心计划】,一起离开地球表面

    【RPC系列合集】

    【专栏】RPC系列(理论)-夜的第一章

    【专栏】RPC系列(理论)-协议与序列化

    【专栏】RPC系列(理论)-动态代理

    【专栏】RPC系列(实战)-摸清RPC骨架

    | 前言

        前一小结我们已经熟悉了实现一个RPC框架,必需和极其重要的组件与功能,这一节我们将会以序列化为起点,构建我们的Sparrow-Rpc。另外我建议进入实战篇后,阅读最好在PC端进行,同步开发。这一节的代码链接如下,每一小节对应一个git tag方便大家阅读。

    https://github.com/JAYqq/my-sparrow-rpc/tree/v1.1

    | 定义传输格式

        我们从客户端出发,将我们的目标请求通过动态代理为我们生成的代理类发送给远端服务然后拿到结果,那么我们的请求中除了方法必要的参数,还有什么“功能性”的属性呢,定义Request也是定义应用层协议的过程,我们需要注意些什么呢?个人拙见,当我们制定协议的时候,最重要的是兼容性,因为协议一定会有一个迭代的过程,如何保证协议升级的过程可以被识别并兼容很重要,而具体的设计方式我们在  【专栏】RPC系列(理论)-协议与序列化 中已经介绍过了,我们直接定义类结构,首先是头部(为了控制文章长度,均省略get/set方法)

    1. public class RpcHeader implements Serializable {
    2. /**
    3. * 版本号
    4. */
    5. String version;
    6. /**
    7. * traceId
    8. */
    9. String traceId;
    10. /**
    11. * 类型
    12. */
    13. String type;
    14. public int getSize() {
    15. return Integer.BYTES + version.getBytes(StandardCharsets.UTF_8).length + Integer.BYTES + traceId.getBytes(StandardCharsets.UTF_8).length + Integer.BYTES + type.getBytes(StandardCharsets.UTF_8).length;
    16. }
    17. }

    值得注意的是getSize()方法定义在这边是为后面序列化做准备,返回的值是头部占用的大小,然后定义Request

    1. public class RpcRequest {
    2. private String nameSpace;
    3. private String serviceName;
    4. private String methodName;
    5. /**
    6. * 方法的参数,这边直接用byte接收,这样避免在序列化时重复序列化这部分数据
    7. */
    8. private byte[] parameters;
    9. /**
    10. * 构造服务签名
    11. * @return
    12. */
    13. public String buildServiceSign() {
    14. return nameSpace + ":" + serviceName;
    15. }
    16. }

    RpcRequest是主要的请求信息,其中我们用nameSpace+serviceName作为一个服务签名表示唯一性,parameters定义为已经经过序列化后的参数。然后我们定义一个RpcCommand作为后面经过Netty的实例格式,这部分设计是因为我们为了课程需要,我将序列化部分分成了自定义序列化协议、jdk序列化以及开源序列化方案三部分,完全只是教学目的。

    1. public class RpcCommand {
    2. RpcHeader header;
    3. byte[] data;
    4. }

    这里的data就是我们通过自定义序列化方式序列化好的RpcRequest信息。ok我们定义完了传输格式后的项目结构如下:

        我们本节的目的是序列化,所以我们暂时只把眼光放到如何把这些类的实例与二进制数据之间转化即可,上面三个类中我们需要序列化的数据应该只有两种:

    • RpcHeader和RpcRequest,由我们自定义序列化协议

    • parameters,我们采用jdk提供的序列化方式

    | 序列化阶段

        我们设计的过程中遵守面向接口编程的方式,因此我们先定义接口类

    1. public interface Serializer {
    2. /**
    3. * 获取T的长度
    4. *
    5. * @return
    6. */
    7. int getSize(T t);
    8. /**
    9. * 序列化
    10. *
    11. * @param o
    12. * @return
    13. */
    14. byte[] serialize(T o, ByteBuffer buffer);
    15. /**
    16. * 反序列化
    17. *
    18. * @return
    19. */
    20. T parse(ByteBuffer buffer);
    21. /**
    22. * 序列器类型
    23. */
    24. byte getType();
    25. Class getSerializerClass();
    26. }

    我们用byte标识序列化类型,由于篇幅原因我们详解自定义的序列化协议,jdk序列化方式与开源的方案我们粗略过一下,只因为序列化的目的都是一个:在对象与二进制数据之间转换。接下来我们实现RpcRequest的序列化类,看看怎么实现。

    public class RpcRequestSerializer implements Serializer<RpcRequest>

        首先getSize方法是RpcRequest类中每个实例所占的字节数,我们再贴一下RpcRequest的类结构

    1. public class RpcRequest {
    2. private String nameSpace;
    3. private String serviceName;
    4. private String methodName;
    5. private byte[] parameters;
    6. }

    所以getSize的方法如下,大小就是三个

    1. @Override
    2. public int getSize(RpcRequest request) {
    3. return request.getNameSpace().getBytes(StandardCharsets.UTF_8).length + Integer.BYTES +
    4. request.getServiceName().getBytes(StandardCharsets.UTF_8).length + Integer.BYTES +
    5. request.getMethodName().getBytes(StandardCharsets.UTF_8).length + Integer.BYTES +
    6. request.getParameters().length + Integer.BYTES;
    7. }

    三个String转换成byte的长度加上paramters的长度,还有四个整型长度是什么?还记得我们理论篇介绍协议的断句么,这里的四个整型代表四个字段的长度,这样方便我们后面创建byte数组来接受四个字段的数据。然后看serialize方法,如何把RpcRequest转换成二进制数据

    1. @Override
    2. public byte[] serialize(RpcRequest o, ByteBuffer buffer)
    3. {
    4. byte[] nameSpaceBytes = o.getNameSpace().getBytes();
    5. buffer.putInt(nameSpaceBytes.length);
    6. buffer.put(nameSpaceBytes);
    7. byte[] serviceBytes = o.getServiceName().getBytes();
    8. buffer.putInt(serviceBytes.length);
    9. buffer.put(serviceBytes);
    10. byte[] methodBytes = o.getMethodName().getBytes();
    11. buffer.putInt(methodBytes.length);
    12. buffer.put(methodBytes);
    13. buffer.putInt(o.getParameters().length);
    14. buffer.put(o.getParameters());
    15. return buffer.array();
    16. }

    结构很清楚,我们的目的是把四个字段放到ByteBuffer中,我们只需要将字段数据放到byte数组中,然后依次put到buffer中就行。关于ByteBuffer,它是jdk的nio包下的一个工具,继承关系如下

    1. public abstract class ByteBuffer
    2. extends Buffer
    3. implements Comparable

    提供了对字节数据的基本操作,除了Buffer的“原生”方法外,ByteBuffer提供了如

    • putInt() 往字节数组尾部添加一个整型转换后的字节数据

    • putDouble() 往字节数组尾部添加一个双精度小数转换后的字节数据

    • getInt() 按顺序读取一个整型大小的字节数据,也就是读取四个字节

    • get(byte[] arr) 按顺序读取一个区域的字节数据放到arr中

    这样api友好的方法,方便我们操作字节数组。然后我们看反序列的parse方法是如何写的

    1. @Override
    2. public RpcRequest parse(ByteBuffer buffer) {
    3. int len = buffer.getInt();
    4. byte[] arrBytes = new byte[len];
    5. buffer.get(arrBytes);
    6. String nameSpace = new String(arrBytes, StandardCharsets.UTF_8);
    7. len = buffer.getInt();
    8. arrBytes = new byte[len];
    9. buffer.get(arrBytes);
    10. String serviceName = new String(arrBytes, StandardCharsets.UTF_8);
    11. len = buffer.getInt();
    12. arrBytes = new byte[len];
    13. buffer.get(arrBytes);
    14. String methodName = new String(arrBytes, StandardCharsets.UTF_8);
    15. len = buffer.getInt();
    16. arrBytes = new byte[len];
    17. buffer.get(arrBytes);
    18. RpcRequest request = new RpcRequest();
    19. request.setNameSpace(nameSpace);
    20. request.setServiceName(serviceName);
    21. request.setMethodName(methodName);
    22. request.setParameters(arrBytes);
    23. return request;
    24. }

    结构也很清晰,逻辑就是先读取下一段的长度len,然后读取len个字节数据作为这个字段的值,只要按照顺序下来解析就行。

        这样一个简单序列化RpcRequest的方法就做好了,但这也仅仅是针对这一个类的实例做序列化与反序列化,但是我们通过这个至少清楚了代码层面的原理。接下来,还记得我们RpcRequest中的paramters字段么,它的原型是我们调用远程方法需要传递的参数类型,这部分的序列化我们采用jdk序列化方式,看看jdk是怎么做的

    1. public byte[] toByteArray(Object obj) {
    2. byte[] bytes = null;
    3. ByteArrayOutputStream bos = new ByteArrayOutputStream();
    4. try {
    5. ObjectOutputStream oos = new ObjectOutputStream(bos);
    6. oos.writeByte(getType());
    7. oos.writeObject(obj);
    8. oos.flush();
    9. bytes = bos.toByteArray();
    10. oos.close();
    11. bos.close();
    12. } catch (IOException ex) {
    13. ex.printStackTrace();
    14. }
    15. return bytes;
    16. }
    17. /**
    18. * 数组转对象
    19. *
    20. * @param bytes
    21. * @return
    22. */
    23. public Object toObject(byte[] bytes) {
    24. Object obj = null;
    25. try {
    26. ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
    27. ObjectInputStream ois = new ObjectInputStream(bis);
    28. //先读取
    29. ois.readByte();
    30. obj = ois.readObject();
    31. ois.close();
    32. bis.close();
    33. } catch (IOException | ClassNotFoundException ex) {
    34. ex.printStackTrace();
    35. }
    36. return obj;
    37. }

    通过  ObjectOutputStream和  ObjectInputStream 实现,由于都是Object所以反序列化后我们通常需要进行类型转化,转化成我们需要的实例类型。

        最后我们再来采用一种开源的序列化方式:Hessian。Hessian是Dubbo的默认序列化协议,使用hessian需要引入依赖

    1. com.caucho
    2. hessian
    3. 4.0.66

    让我们看看Hessian的序列化与反序列的使用方式

    1. @Override
    2. public byte[] serialize(Object o, ByteBuffer buffer) {
    3. try {
    4. ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
    5. Hessian2Output out = new Hessian2Output(outputStream);
    6. out.writeObject(o);
    7. out.flush();
    8. return outputStream.toByteArray();
    9. } catch (Exception e) {
    10. throw new SecurityException("serializer error");
    11. }
    12. }
    13. @Override
    14. public Object parse(ByteBuffer buffer) {
    15. try {
    16. ByteArrayInputStream inputStream = new ByteArrayInputStream(buffer.array());
    17. Hessian2Input input = new Hessian2Input(inputStream);
    18. Object o = input.readObject();
    19. return o;
    20. } catch (Exception e) {
    21. throw new SerializeException("parse object error");
    22. }
    23. }

    看起来和jdk序列化方式差不多(说明封装的很好),使用 Hessian2Output 和Hessian2Input 进行操作,在我们的Sparrow-Rpc中,我们的服务响应Response后续我们会使用Hessian进行序列化与反序列化,这样我们可以了解到三种序列化的使用方式,就很nice(虽然很不规范)。除此之外,我们自定义了序列化异常类,便于定位异常类型。

    1. public class SerializeException extends RuntimeException {
    2. public SerializeException(String message) {
    3. super(message);
    4. }
    5. }

        这样,我们就完成了我们通信需要用到的序列化部分的功能,但是现在还有一个问题,就是如何优雅的使用我们想要的序列化方式。在真实场景中,有可能是这样的情况,Sparrow-Rpc开始采用的是jdk序列化方式,但是后面想替换成Hessian,这个步骤你想“无感升级”,那么我们就缺少一种可拔插的开发方式。所以 SPI 的出现就解决了这一问题。

    | SPI

          SPI(Service Provider Interface),是JDK内置的一种 服务提供发现机制,可以用来启用框架扩展和替换组件。简单来说,我们利用SPI可以无缝替换上面的Serializer接口的实现方式,只要修改配置文件即可,调用者与SPI形成了下面的关系,可以看出我们想要替换序列化方式是对调用者透明的,这也是功能解耦的体现。

        SPI要求我们需要在META-INF/services/下编写配置文件,目的是为了告诉JDK需要帮我们生成什么实现类的实例,由于我们用到了多种序列化方式,因此我们的配置文件是这样的 (com.sparrow.rpc.core.serialize.Serializer)

    1. com.sparrow.rpc.core.serialize.impl.ObjectSerializer
    2. com.sparrow.rpc.core.serialize.impl.ObjectArraysSerializer
    3. com.sparrow.rpc.core.serialize.impl.RpcRequestSerializer
    4. com.sparrow.rpc.core.serialize.impl.HessianSerializer

    为了序列化有一个统一出口,我们实现一个SerializeSupport支持类,对外提供一致序列化方式,我们先定义了两个Map成员

    1. private staticMap> typeSerializerMap = new ConcurrentHashMap<>();
    2. private static Map, Serializer> classSerializerMap = new ConcurrentHashMap<>();
    3. static {
    4. //加载所有序列器
    5. Collection serializers = SpiSupport.loadAll(Serializer.class);
    6. serializers.forEach(serializer -> {
    7. typeSerializerMap.put(serializer.getType(), serializer);
    8. classSerializerMap.put(serializer.getSerializerClass(), serializer);
    9. });
    10. }

    我们在序列化时会先在字节数组的第一位放一个Byte类型的值表示序列化类型

    typeSerializerMap 是为了在做反序列时,可以先拿到序列化类型,然后找到对应的序列化器做反序列化;同理classSerializerMap也是为了在序列化时做准备,所以我们的序列化与反序列化方法就是

    1. public static byte[] serialize(T t) {
    2. Serializer serializer = (Serializer) classSerializerMap.get(t.getClass());
    3. if (Objects.isNull(serializer)) {
    4. throw new IllegalArgumentException(String.format("Cannot find correct serializer for class:%s", t.getClass()));
    5. }
    6. ByteBuffer buffer = ByteBuffer.allocate(serializer.getSize(t) + 1);
    7. //先放上类型
    8. buffer.put(serializer.getType());
    9. return serializer.serialize(t, buffer);
    10. }
    11. public static E parse(byte[] bytes) {
    12. ByteBuffer buffer = ByteBuffer.wrap(bytes);
    13. //先取出类型
    14. byte type = buffer.get();
    15. Serializer serializer = typeSerializerMap.get(type);
    16. if (Objects.isNull(serializer)) {
    17. throw new IllegalArgumentException(String.format("Unknown type:%s", type));
    18. }
    19. Object rs = serializer.parse(buffer);
    20. return (E) rs;
    21. }

    | 小结

        这样我们就完成了序列化部分的工作,只要调用SerializeSupport的对应方法,就能利用SPI机制动态生成的序列化器实例进行操作了。具体的序列化与反序列化部分也是利用了ByteBuffer进行读写,只要理清了逻辑,就能明白序列化的工作内容了。

        这一节都到这里,下一节我们会讲解Netty相关的部分,看看我们怎么将序列化好的数据发送出去,又怎么接受二进制数据反序列化成我们想要的结果,Netty到底为我们做了哪些工作呢?下期见旁友~

  • 相关阅读:
    图机器学习(GML)&图神经网络(GNN)原理和代码实现(前置学习系列二)
    Wordpress页面生成器:Elementor 插件制作网站页面教程(图文完整)
    【计算机网络】网络层:外部网关协议BGP
    C++小程序——“靠谱”的预测器
    小白学习c++的的第一节课
    撞上元宇宙冷门新职业 我变年入百万打工人
    C语言水平测试题 过关斩将(3)辗转相除法,前n项求和,整数的正序分解,求最大公约数
    计算机与软件技术系毕业设计(论文)实施意见-计算机毕业设计论文怎么写
    js 中 Map 和 Set 区别
    SpringCloud Feign异步调用传参问题
  • 原文地址:https://blog.csdn.net/scwMason/article/details/126494353