第3章 - Dubbo体系结构

Dubbo体系结构

Dubbo的体系结构如图3-1所示:

可见,Dubbo的核心组件为:注册中心、服务提供方、服务消费方、监控中心,其中,注册中心、服务提供方、服务消费方在上一章都有所耳闻了,这里的监控中心的主要作用就是统计服务的调用次数和调用时间。

对图3-1里的每个步骤说明如下:

  1. 服务提供方在启动时,向注册中心注册自己提供的服务。
  2. 服务消费方在启动时,向注册中心订阅自己所需的服务。
  3. 注册中心返回服务提供方的地址列表给消费方。如果有变更,注册中心会基于长连接去推送变更的数据给消费方。
  4. 服务消费方,从提供方的地址列表中,基于一定的负载均衡算法,选择一台提供方进行调用,其中如果调用失败,会再选另一台调用。
  5. 服务消费方和提供方,在内存中分别统计累计的调用次数和调用时间,并且定时发送统计数据到监控中心。

服务容器

其实服务提供方内还有个服务容器的概念。

服务容器是一个可独立运行的的启动程序,负责启动、加载以及保持运行服务提供方。因为Dubbo服务不需要Tomcat等web容器的功能,如果硬要用web容器去加载服务提供方,会增加复杂性,也浪费资源。

我们会在后面的内容中着重讲服务容器。

协议

组件之间是通过什么协议通信的呢?

Dubbo目前支持多种协议,如下:

dubbo,hessain2,http,injvm,jsonrpc,memcached,native-thrift,thrift,redis,rest,rmi,webservice,xml

注意,这里有些协议不是Dubbo暴露给用户使用的协议,有些是Dubbo自用的,如redis、memcached等。其中最常用的协议是dubbo协议。

dubbo协议特点:

  1. 是每个提供方和每个消费方使用的是单一的长连接。
  2. 使用了NIO异步通讯技术(同步非阻塞IO模型,相当于就是一个线程处理许多客户端的请求,通过一个线程轮询许多通道,每次就获取一批就绪了的通道,然后对每个就绪了的通道的事件进行处理即可。一个客户端请求写入一些数据到某个通道,但线程不需要等待它完全写入,这个线程可以同时去做别的事情)。
  3. 适合大并发小数据量的服务调用。
  4. 传输的协议选用的是TCP协议。
  5. 对于传输的数据默认采用hessian2二进制序列化方式。

注意,因dubbo协议采用单一长连接,dubbo协议并不适合传送大数据量的服务,比如传文件、视频、超大字符串等,否则会阻塞住该连接。

为什么dubbo采用异步单一长连接?

因为服务的现状大都是服务提供方少,服务的消费方多,通过单一长连接,保证单一消费方不会压垮提供方,减少多次建立连接的握手验证开销等(若是短连接的话,每次发送请求之前,就需要每次都先重新建立连接)。

使用异步NIO,能够复用到线程池,有效防止C10K问题(C10k问题意思是:服务器如何支持10k个并发连接的问题,如果为每个连接分配一个独立的线程/进程,耗费的资源太大)。

dubbo协议报文格式

dubbo协议的报文格式如图3-2所示:

  • MAGIC:魔法数,用来判断该数据包是不是来自于dubbo协议,值是常量0xdabb(二进制为:1101101010111011)。
  • Flag:标志位,一共8位。低四位用来表示用的什么序列化方式。高四位中,第一位为1时表示是request请求,第二位为1时表示双向传输(即有response),第三位为1时表示是心跳ping事件。
  • Status:状态位, 表示请求或响应的状态(如成功、消费方超时、提供方超时等)。
  • Invoke Id:请求的唯一识别id,long类型。
  • Body Length:消息体body的长度,integer类型,用来记录Body Content有多少个字节。
  • Body Content:请求参数或响应参数在抽象序列化之后就存储于此区域。

协议报文会转成字节流在网络中传输。

序列化方式

把对象转换为字节流的过程称为序列化,把字节流恢复为对象的过程称为反序列化。只有通过合适的序列化,提供方和消费方才能够成功完成对象的传输。

序列化对于远程调用的响应速度、吞吐量、网络带宽消耗起着至关重要的作用,是提高分布式系统性能的最关键因素之一。

Dubbo支持哪些序列化方式呢?

除了上面说的hessian2,还有avro、json、jdk自带的序列化方式、fst、kryo、protobuf、protostuff。

这里分别介绍下常用的几种序列化方式:

hessian2

hessian2是一种跨语言的高效的二进制序列化方式。

它有一些约束:

  1. 方法参数及返回值需实现Serializable接口。
  2. 方法参数及返回值不能够自定义地实现List、Map、Number、Date、Calendar等接口,只能用jdk自带的实现,因为hessian2有做特殊处理,自定义实现类中的属性值会丢失或是不能正常运行。
  3. hessian2序列化后,只会传类的属性值和值的类型,不会传类的方法或静态变量。

关于消费方关于获取到的类的序列化差异是否可允许,这里有张表:

数据通讯方向 情况 结果
A -> B A多一种属性 不抛异常,只是A多的那个属性的值,B没有,其他正常
A -> B A是枚举类。A多一种枚举实例,且A传输了多出来的这个枚举实例 抛异常
A -> B A是枚举类。A多一种枚举实例,且A没有传输多出来的这个枚举实例 不抛异常
A -> B A和B的属性名相同,但类型存在差异 抛异常
A -> B A和B的serial id不相同 不抛异常

若提供方的接口增加了方法,如果该方法不是消费方需要的,对消费方无影响。

方法参数及返回的类增加了属性,如果消费方并不需要新属性,则不用重新部署。

方法参数及返回的类属性名有差异,消费方序列化不会抛异常,但是如果消费方不重新部署,不管方法参数还是返回的类,属性名变化的属性值是获取不到的。

如上所述,提供方和消费方对于获取到的类的序列化结果并不需要完全一致,遵循着所谓的最大匹配原则。

json

json是一种轻量级的数据文本传输格式。用来作序列化的方式的话,调试起来会比较方便,因为是文本传输的数据流是肉眼可读的。

缺点是相对于其他序列化方式来说,会具有许多冗余的数据(如括号等字符),传输的效率低,传输的耗时长,占用带宽大。

jdk自带的序列化方式

如果是选用jdk自带的序列化方式的话,要被序列化的对象以及其中的Object属性都必须实现Serializable接口,否则会报异常。此外,static和transient关键字修饰的变量将不会被实例化,也不能做到跨不同的编程语言传输。

一般很少会去选用jdk自带的序列化方式,因为jdk自带的序列化方式的性能远差于hessian2序列化方式。

jdk反序列化漏洞

jdk反序列化即由字节流还原成对象,一般用ObjectInputStream类的readObject()方法来实现jdk反序列化。下面展示利用这个漏洞的一个例子:

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public class SerializeTest {
    public static void main(String[] args) throws Exception{
        // 这是我们要序列化的对象
        Person person = new Person("jay");

        // 将把序列化数据写入到一个文件
        FileOutputStream fos = new FileOutputStream("object.ser");
        ObjectOutputStream os = new ObjectOutputStream(fos);
        os.writeObject(person);
        os.close();

        // 从文件中读回数据进行反序列化
        FileInputStream fis = new FileInputStream("object.ser");
        ObjectInputStream ois = new ObjectInputStream(fis);
        person = (Person) ois.readObject();

        // 打印结果
        System.out.println(person.getName());
        ois.close();
    }
}

class Person implements Serializable {
    private String name;

    public Person(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException{
        in.defaultReadObject();
        this.name += " hacked.";
    }
}

打印的结果是:

jay hacked.

在构造反序列化的对象时,必须是目标应用上运行环境中存在的类,这样才能被目标应用解析并生成新的对象。目标应用的类中,如果有readObject方法。我们构造这个类的对象,在解析过程中会优先执行这个类的readObject方法。

因此攻击者有可能利用了jdk反序列化漏洞,他们在将进行反序列化的地方传入攻击者的序列化代码,如果应用对这些不可信的数据做了jdk反序列化处理,那么攻击者可以通过构造恶意输入,反序列成的非预期的对象在产生过程中就有可能带来任意恶意代码的执行。

不过由于类是自己目标应用定义的,一般开发时也不会覆写出逻辑很不安全的readObject方法,所以一般情况下这个安全问题不会暴露出来,但是如果是第三方类的话,这个漏洞就就有可能爆发出来了(因为一般调用者很难清楚第三方类会不会提供了逻辑不安全的readObject方法)。

其实不只是jdk爆发过反序列化漏洞问题,fastjson、jackson也曾出现过这种反序列化的漏洞问题。

总而言之,反序列化漏洞的根本原因是,没有控制好可被反序列化的类型的范围。

kryo

kryo是一个高效的java序列化/反序列化库,目前有被Twitter、yahoo、Groupon、Apache等等企业或组织使用,在spark、hive、Storm等大型开源项目中也被应用得较多。

hessian2是一个比较老的序列化实现了,而且它是跨语言的,所以不是单独针对java进行优化的,kryo序列化方式的性能通常情况下是显著优于hessian2的。此外,Dubbo RPC实际上在企业中得使用中几乎完全都是Java to Java的远程调用,其实一般也没有必要采用跨语言的序列化方式。

kryo在序列化对象时,首先会序列化其类的全限定名,但是往往这样一直重复地序列化同样的类的全限定名是很低效的。可以通过注册来提高序列化的性能,通过注册kryo可以将类的全限定名抽象为一个数字id,即用一个数字id代表全限定名,这样就会高效些。提供方和消费方只需要通过这个id就可以知道这个类的序列化或反序列化方式了。

因此,要让kryo完全发挥出高性能的序列化,最好将所有需要被序列化的类注册到Dubbo中,比如,可以调用以下的方法:

// kryo内部会指定serializer和id
SerializableClassRegistry.registerClass(XXX.class);

Dubbo已经自动将jdk中的常用类进行了注册,所以我们不需要重复注册它们(当然重复注册了也没有任何影响),包括:

Dubbo默认注册好的类
ArrayList
byte[]
BitSet
boolean[]
Byte
Boolean
BigInteger
BigDecimal
ConcurrentHashMap
char[]
Calendar
Character
double[]
Date
Double
Float
float[]
GregorianCalendar
Hashtable
HashSet
HashMap
InvocationHandler
Integer
int[]
LinkedList
Long
long[]
Object
Pattern
StringBuilder
String
SynchronizedRandomAccessList
SynchronizedSet
short[]
SimpleDateFormat
SynchronizedCollection
SynchronizedList
SynchronizedMap
StringBuffer
SynchronizedSortedSet
Short
SynchronizedSortedMap
TreeSet
UnmodifiableRandomAccessList
UnmodifiableSet
UUID
UnmodifiableSortedSet
UnmodifiableMap
UnmodifiableSortedMap
URI
UnmodifiableList
UnmodifiableCollection
Vector
Void

需要考虑要保证服务提供方和服务消费方都以同样的顺序(或者id)来注册类,避免错位,否则反序列化时会抛出异常(因为根据不一致的id查询到的反序列化方式是错误的)。

由于注册被序列化的类仅仅是出于性能优化的目的,所以即使忘记注册某些类也可以正常使用。事实上,即使不注册任何类,kryo的性能依然普遍优于hessian2序列化方式。

如果被序列化的类中不包含无参的构造函数,则在kryo的序列化时性能将会大打折扣,因为如果没有显式的无参的构造函数Dubbo在底层将用jdk序列化来透明地取代kryo序列化。所以,尽可能为每一个被序列化的类添加无参构造函数是一种最佳实践。

kryo不需要被序列化的类实现Serializable接口,但还是建议每个被序列化类都去实现它,因为这样可以保持和jdk序列化的兼容性。

protobuf

protobuf是Google推出的一个跨平台、语言中立的序列化方式,它定义了一套结构化数据定义的协议,同时也提供了相应的生成多种语言的compiler工具(用来将语言中立的描述转化为相应语言的具体描述)。protobuf序列化方式,在性能和跨语言上的效果都挺好的。

protobuf实现了语言中立的服务定义。没有protobuf的时候,Dubbo的服务定义需要和具体的编程语言绑定,无法提供一种语言中立的服务描述格式。比如Java语言这里定义Interface接口后,到了其他语言的时候又得重新以另外的格式预定义一遍,使用protobuf后就可以实现了语言中立的服务定义。

此外,它还具有一定的安全性,由于反序列化的范围和输出的内容格式都是compiler在编译时预生成的,因此会绕过了类似Java反序列化漏洞的问题。因为,protobuf在idl里定义好了package范围,protobuf的代码都是自动生成的,怎么处理输入流的二进制数据的逻辑也都是固定的,protobuf把一切都框住了,少了灵活性,自然也就避免了这个漏洞。

缺点,Java使用protobuf除了要引入protobuf必要的maven依赖和插件以外,还需要学习protobuf的语法编写.proto文件,如下面的例子:

syntax = "proto3";

option java_multiple_files = true;
option java_package = "org.apache.dubbo.hello";
option java_outer_classname = "HelloWorldProto";
option objc_class_prefix = "HLW";

package helloworld;

message UserMessage {
    string name = 1;
    int32 id = 2;
    string message = 3;
    MessageType type=4;
}

enum MessageType {
    SYSTEM = 0;
    CUSTOMER = 1;
    OTHER = 2;
}

对于Java语言来说,上面的例子生成的实体类会是:

public class UserMessage {
    public String name;
    public int id;
    public String message;
    public MessageType type;
}
public enum MessageType {
    SYSTEM,
    CUSTOMER,
    OTHER,
}
posted @ 2021-04-14 00:07  msl12  阅读(123)  评论(0编辑  收藏  举报