平台搭建---大数据框架---RPC 框架

来源1
来源2
RPC,全称为Remote Procedure Call,即远程过程调用,它是一个计算机通信协议。它允许像调用本地服务一样调用远程服务。它可以有不同的实现方式。如RMI(远程方法调用)、Hessian、Http invoker等。另外,RPC是与语言无关的。

1、本地过程调用

RPC就是要像调用本地的函数一样去调远程函数。在研究RPC前,我们先看看本地调用是怎么调的。假设我们要调用函数Multiply来计算lvalue * rvalue的结果:

1 int Multiply(int l, int r) {
2    int y = l * r;
3    return y;
4 }
5 
6 int lvalue = 10;
7 int rvalue = 20;
8 int l_times_r = Multiply(lvalue, rvalue);

那么在第8行时,我们实际上执行了以下操作:

  • 将 lvalue 和 rvalue 的值压栈
  • 进入Multiply函数,取出栈中的值10 和 20,将其赋予 l 和 r
  • 执行第2行代码,计算 l * r ,并将结果存在 y
  • 将 y 的值压栈,然后从Multiply返回
  • 第8行,从栈中取出返回值 200 ,并赋值给 l_times_r

以上5步就是执行本地调用的过程。

2、远程过程调用带来的新问题

在远程调用时,我们需要执行的函数体是在远程的机器上的,也就是说,Multiply是在另一个进程中执行的。这就带来了几个新问题:

  • Call ID映射。我们怎么告诉远程机器我们要调用Multiply,而不是Add或者FooBar呢?在本地调用中,函数体是直接通过函数指针来指定的,我们调用Multiply,编译器就自动帮我们调用它相应的函数指针。但是在远程调用中,函数指针是不行的,因为两个进程的地址空间是完全不一样的。所以,在RPC中,所有的函数都必须有自己的一个ID。这个ID在所有进程中都是唯一确定的。客户端在做远程过程调用时,必须附上这个ID。然后我们还需要在客户端和服务端分别维护一个{函数 <--> Call ID}的对应表。两者的表不一定需要完全相同,但相同的函数对应的Call ID必须相同。当客户端需要进行远程调用时,它就查一下这个表,找出相应的Call ID,然后把它传给服务端,服务端也通过查表,来确定客户端需要调用的函数,然后执行相应函数的代码。
  • 序列化和反序列化。客户端怎么把参数值传给远程的函数呢?在本地调用中,我们只需要把参数压到栈里,然后让函数自己去栈里读就行。但是在远程过程调用时,客户端跟服务端是不同的进程,不能通过内存来传递参数。甚至有时候客户端和服务端使用的都不是同一种语言(比如服务端用C++,客户端用Java或者Python)。这时候就需要客户端把参数先转成一个字节流,传给服务端后,再把字节流转成自己能读取的格式。这个过程叫序列化和反序列化。同理,从服务端返回的值也需要序列化反序列化的过程。
  • 网络传输。远程调用往往用在网络上,客户端和服务端是通过网络连接的。所有的数据都需要通过网络传输,因此就需要有一个网络传输层。网络传输层需要把Call ID和序列化后的参数字节流传给服务端,然后再把序列化后的调用结果传回客户端。只要能完成这两者的,都可以作为传输层使用。因此,它所使用的协议其实是不限的,能完成传输就行。尽管大部分RPC框架都使用TCP协议,但其实UDP也可以,而gRPC干脆就用了HTTP2。Java的Netty也属于这层的东西。

这里写图片描述
所以,要实现一个RPC框架,其实只需要把以上三点实现了就基本完成了。Call ID映射可以直接使用函数字符串,也可以使用整数ID。映射表一般就是一个哈希表。序列化反序列化可以自己写,也可以使用Protobuf或者FlatBuffers之类的。网络传输库可以自己写socket,或者用asio,ZeroMQ,Netty之类。
原作者实现的一个RPC框架tinyrpc
rpc框架做的最重要的一件事情就是封装,调用者和被调用者的通讯细节,客户端代理负责向调用方法的方法名参数返回值包等信息根据通信协议组织成报文发送给服务端,服务端解析报文,根据客户端传递的信息执行对应的方法,然后将返回值安装协议组织成报文发送给客户端,客户端再解析出来。RPC屏蔽了底层的实现细节,让调用者无需关注网络通信,数据传输等细节。

3、RPC框架的实现思路

RPC的核心原理:RPC能够让本地应用简单、高效地调用服务器中的过程(服务)。它主要应用在分布式系统。如Hadoop中的IPC组件。但怎样实现一个RPC框架呢?

从下面几个方面思考,仅供参考:

  • 1.通信模型:假设通信的为A机器与B机器,A与B之间有通信模型,在Java中一般基于BIO或NIO;。
  • 2.过程(服务)定位:使用给定的通信方式,与确定IP与端口及方法名称确定具体的过程或方法;
  • 3.远程代理对象:本地调用的方法(服务)其实是远程方法的本地代理,因此可能需要一个远程代理对象,对于Java而言,远程代理对象可以使用Java的动态对象实现,封装了调用远程方法调用;
  • 4.序列化,将对象名称、方法名称、参数等对象信息进行网络传输需要转换成二进制传输,这里可能需要不同的序列化技术方案。如:protobuf,Arvo等。

4、逐步实现简单RPC框架

来源:一个简单RPC框架是如何炼成的
试图通过简单的编程来模拟和解释RPC的原理和过程,不是真实的RPC。

4.1、开局篇


总结下来就是有4块核心内容

  • RPC数据的传输。如上面的RPCConnector,RPCChannel。它们主要负责数据传输这一块, 具体客户端与服务器之间的连接是不是socket连接,是原始tcp连接还是使用http,这些RPC协议本身不做任何规定。那么我们的任务就是抽象出这样一个传输层。
  • RPC消息。如上面的RPCProtocol, 以及encode,decode方法。 因为RPC是远程调用,所以没办法直接函数调用,于是就必须用一套专门的协议,去表示调用以及调用结果。另外,因为实际应用基本都是跨机器连接,所以无法直接传递内存变量,也就是说还需要将消息编码成 诸如字符串一类的可以跨设备传输的内容。具体的RPC消息的封装协议很多,常见的是基于xml,json封装的。那么我们的任务就是抽象出这样一个协议层。
  • RPC服务注册。如上面Callee –>export。 服务端具体支持哪些调用,收到来自客户端的RPC请求后,怎样去调用真正的需要执行的方法,这些内容也是一个完整的RPC框架必须考虑的。一些稍微高级一点的框架,都是可以服务自动注册的,现在主流的RPC框架,还支持通过 IDL(Interface Definition Language)来定义远程接口,实现跨语言的RPC 。那么我们的任务就是抽象出一个RPC服务的注册机制。
  • RPC消息处理。如上面的RPCInvoker。这里其实与RPC本身关系不大,一般就是考虑支持异步/同步调用。 这一部分,大概我也会做一些说明,但不是这个系列的重点。

4.2、制定RPC消息

下面,我们先看一个普通的过程调用

#-*-coding=utf-8-*-


class Client(object):
    def __init__(self):
        self.remote=None

    # 内部是委托给远程remote对象来获取结果。这里的远程对象指另一个类实例。真正的功能是由另一个类的实例来完成的。
    def sayHello(self):
        if self.remote:return self.remote.sayHello()
        else:return None

class Server(object):
    def __init__(self):
        pass

    def sayHello(self):
        return 'Hello World'

if __name__=='__main__':
    server=Server()
    client=Client()
    client.remote=server#此时就把服务类的实例赋给了客户类
    print(client.sayHello())#当client.remote有值时,client.sayHello()方法返回结果是client.remote.sayHello(),而client.remote又被赋予了一个server对象;server对象具有sayHello()方法,其功能是返回'Hello World'值,即client.remote.sayHello()返回结果也是'Hello World',进行client.sayHello()的返回结果也是'Hello World'
结果输出:Hello World

这是一个常见的过程调用的例子,client调用sayHello,实际委托给Server的sayHello方法来实现。但他不是RPC调用,因为起码不是远程的,另外,也没有我们提到的四个核心内容。于是我们的任务就是通过一点点的代码修改,为其引入RPC框架。
第一步,订协议。
RPC请求:Request, 包含一个请求id 和 一个请求命令,如‘sayHello’

#Request的功能就是定义要传递的参数
class Request(object):
    '''''
    @RPC请求,包含命令id和请求内容两部分。这个实现,与具体的RPC协议相关。
    @这里是简化起见,采用python自身的字典作为其协议的数据结构
    '''
    def __init__(self):
        '''''
        Constructor
        '''
        self.id=0#id的作用在于将Request和Response建立绑定关系.在异步调用时就有用
        self.command=None#sayHello

    def __str__(self):
        return ''.join(('id: ', str(self.id), '   command: ', str(self.command)))

同样的,对RPC Response,也定义如下

#Respone就是定义接收参数
class Respone(object):
    '''''
    @RPC回复。 包含答复id和执行结果两部分内容。其中答复id与对应的请求id一致。
    @简单起见,协议的实现使用python自家的字典
    '''
    def __init__(self):
        '''''
        Constructor
        '''
        self.id=0
        self.result=None

    def __str__(self):
        return ''.join(('id: ', str(self.id), '   result: ', str(self.result)))

定义好协议之后,我就对Client稍作修改,将原来直接的接口调用self.remote.sayHello更改为 send Request (command=’sayHello’)

# 内部是委托给远程remote对象来获取结果。
    def sayHello(self):
        req = Request()
        req.id = 1
        req.command = 'sayHello'
        return self.request(req)

    def request(self,req):
        rsp=self.remote.procRequest(req)#将请求消息发送给远程服务端。但因为传输层这里还没实现,所以先暂时还是直接调用远端接口
        return rsp.result

然后,服务端也要相应修改,需要根据request请求中的command命令,调用具体的方法,并将执行结果封装到Response中,返回给客户端。

def procRequest(self, req):
    rsp = Respone()
    rsp.id = req.id
    if req.command == 'sayHello':
        rsp.result = self.sayHello()
    else:
        raise Exception("unknown command")
    return rsp

整个过程如下:

#-*-coding=utf-8-*-

class Request(object):
    '''''
    @RPC请求,包含命令id和请求内容两部分。这个实现,与具体的RPC协议相关。
    @这里是简化起见,采用python自身的字典作为其协议的数据结构
    '''
    def __init__(self):
        '''''
        Constructor
        '''
        self.id=0#id的作用在于将Request和Response建立绑定关系.在异步调用时就有用
        self.command=None#sayHello

    def __str__(self):
        return ''.join(('id: ', str(self.id), '   command: ', str(self.command)))

class Respone(object):
    '''''
    @RPC回复。 包含答复id和执行结果两部分内容。其中答复id与对应的请求id一致。
    @简单起见,协议的实现使用python自家的字典
    '''
    def __init__(self):
        '''''
        Constructor
        '''
        self.id=0
        self.result=None

    def __str__(self):
        return ''.join(('id: ', str(self.id), '   result: ', str(self.result)))

class Client(object):
    def __init__(self):
        self.remote=None

    # 内部是委托给远程remote对象来获取结果。
    def sayHello(self):
        req = Request()
        req.id = 1
        req.command = 'sayHello'
        return self.request(req)

    def request(self,req):
        rsp=self.remote.procRequest(req)#将请求消息发送给远程服务端。但因为传输层这里还没实现,所以先暂时还是直接调用远端接口
        return rsp.result

class Server(object):
    def __init__(self):
        pass

    def sayHello(self):
        return 'Hello World'

    def procRequest(self, req):
        rsp = Respone()
        rsp.id = req.id
        if req.command == 'sayHello':
            rsp.result = self.sayHello()
        else:
            raise Exception("unknown command")
        return rsp

if __name__=='__main__':
    server=Server()
    client=Client()
    client.remote=server
    print(client.sayHello())

输出结果也为:Hello World

到这里,RPC框架中的RPC消息已经初具雏形,不过

  • 我们并没有实现相应的encode和decode方法,没有基于可以跨设备的字符串传输,而是直接的内存变量传递。
  • 现在的RPC request不支持带参数的请求命令。如add(a, b), 如何在RPC消息中描述参数a,b 。

4.3、实现带参数的RPC调用

既然是要带参数,那只能扩展原来的Request消息了,加个parameter成员,用于表示参数,具体的格式采用字典方式,{ ’arg1‘, arg1, ‘arg2’, arg2 ,….}。 这样就可以解决多参数的表示问题。

class Request(object):
    '''''
    @RPC请求,包含命令id和请求内容两部分。这个实现,与具体的RPC协议相关。
    @这里是简化起见,采用python自身的字典作为其协议的数据结构
    '''
    def __init__(self):
        '''''
        Constructor
        '''
        self.id=0#id的作用在于将Request和Response建立绑定关系.在异步调用时就有用
        self.command=None#sayHello
        self.parameter = {}

    def __str__(self):
        return ''.join(
            ('id: ', str(self.id), '    command: ', str(self.command), '    parameter: ', str(self.parameter)))

add(a=1, b=2)的RPC 请求就是这个样子了

Request : id = 3, command = 'add', parameter = {'a':1, 'b':2}  

对应的,客户端的add方法,我们可以这么写

def add(self, a, b):  
    req = Request()  
    req.id = 3  
    req.command = 'add'  
    req.parameter = {'a':a, 'b':b}  
    return self.request(req)

那么服务端收到这个RPC请求后,怎么处理得到参数呢?一个传统而稍显笨拙的方式是:

def add(self, a, b):  
    return a + b  

def procReqeust__add(self, req):  
    parameter = req.parameter  
    a = parameter.get('a')  
    b = parameter.get('b')  
    return self.add(a, b)  

这种方式的缺点就是每一个RPC调用,都要怎么处理一下,烦死了,没有任何技术含量的纯苦力活,但还考验细心,一不小心搞错a或者b的名字了,呵呵,等着被请喝茶吧。
修改如下:

def procReqeust__add(self, req):  
    parameter = req.parameter  
    return self.add(**parameter)  

对上面**parameter不懂的同学自行度娘。这里只是简单解释一下:**parmater的作用同那块笨拙的代码一样,但有一个前提条件,即使add声明时,参数变量名a,b不能变。
至此,使用这种新的方式,我们server的代码就是这个样子的,对没有参数的方法,上面**也是可以放心使用的

def procRequest(self,req):  
    rsp = Response()  
    rsp.id = req.id     
    if req.command == 'sayHello':  
        rsp.result = self.sayHello(**req.parameter)  
    elif req.command == 'add':  
        rsp.result = self.add(**req.parameter)  
    else:  
        raise Exception("unknown command") 
    return rsp

整个过程如下:

#-*-coding=utf-8-*-

class Request(object):
    '''''
    @RPC请求,包含命令id和请求内容两部分。这个实现,与具体的RPC协议相关。
    @这里是简化起见,采用python自身的字典作为其协议的数据结构
    '''
    def __init__(self):
        '''''
        Constructor
        '''
        self.id=0#id的作用在于将Request和Response建立绑定关系.在异步调用时就有用
        self.command=None#sayHello
        self.parameter={}

    def __str__(self):
        return ''.join(('id: ', str(self.id), '   command: ', str(self.command), '    parameter: ', str(self.parameter)))

class Respone(object):
    '''''
    @RPC回复。 包含答复id和执行结果两部分内容。其中答复id与对应的请求id一致。
    @简单起见,协议的实现使用python自家的字典
    '''
    def __init__(self):
        '''''
        Constructor
        '''
        self.id=0
        self.result=None

    def __str__(self):
        return ''.join(('id: ', str(self.id), '   result: ', str(self.result)))

class Client(object):
    def __init__(self):
        self.remote=None

    # 内部是委托给远程remote对象来获取结果。
    def sayHello(self):
        req = Request()
        req.id = 1
        req.command = 'sayHello'
        return self.request(req)

    def add(self, a, b):
        req = Request()
        req.id = 3
        req.command = 'add'
        req.parameter = {'a': a, 'b': b}
        return self.request(req)

    def request(self,req):
        rsp=self.remote.procRequest(req)#将请求消息发送给远程服务端。但因为传输层这里还没实现,所以先暂时还是直接调用远端接口
        return rsp.result

class Server(object):
    def __init__(self):
        pass

    def sayHello(self):
        return 'Hello World'

    def add(self, a, b):
        return a + b

    def procRequest(self, req):
        rsp = Respone()
        rsp.id = req.id
        if req.command == 'sayHello':
            rsp.result = self.sayHello(**req.parameter)
        elif req.command == 'add':
            rsp.result = self.add(**req.parameter)
        else:
            raise Exception("unknown command")
        return rsp
if __name__=='__main__':
    server=Server()
    client=Client()
    client.remote=server
    print(client.sayHello())
    print(client.add(2,3))

输出结果如下:

Hello World
5

4.4、实现RPC消息的编解码

实际的RPC应用基本都是跨机器连接,所以无法直接传递内存变量,也就是说还需要将消息编码成 诸如字符串一类的可以跨设备传输的内容。具体的RPC消息的封装协议很多,常见的是基于xml,json封装的。但如果抽象一下,实际也就是一个编解码,管你编码成什么内容呢,就是不编码也可以。管他黑猫白猫,只要能传过去,就是好猫。
利用python里的两个运算。 str 和eval。
假设 一个字典msg = { ‘a’ : 1, ‘b’ : 2}. 那么str(msg) = ” { ‘a’ : 1, ‘b’ : 2}”, 注意变成字符串喽。
然后eval(” { ‘a’ : 1, ‘b’ : 2}”)–>msg, 做一个eval运算,又从字符串变成 字典变量了。
于是编码时,先将RPC消息转换成dict,然后调用str编码成字符串。
解码时,先调用eval 得到dict对象,然后再转换为具体的RPC消息对象
设计已定,剩下的就只是code filling。
先修改一下原来Request的str方法,返回一个dict的字符串表示。对Response也做类似处理

class Request(object):
    def __str__(self):
        return str({'id': self.id, 'command': self.command, 'parameter': self.parameter})

然后引入encode方法

@classmethod  
def encode(cls, message):  
    if isinstance(message, Request):  
        return str(message)  
    elif isinstance(message, Response):  
        return str(message)  
    elif isinstance(message, Notification):  
        return str(message)  
    else:  
        raise Exception('unknown type when encode')  

同样的,引入decode方法,稍微复杂一些。主要的麻烦在于如何区分解码出来的是Response还是Request
我的办法是比较投机的,直接根据字典的内容去判断。有command字段的肯定是request,有result字段的肯定是response

@classmethod      
    def decode(cls, data):  
        info = eval(data)  
        if 'command' in info:  
            request = Request()  
            request.id = info.get('id')  
            request.command = info.get('command')   
            request.parameter = info.get('parameter', {})  
            return request  
        elif 'result' in info:  
            response = Response()  
            response.id = info.get('id')  
            response.result = info.get('result')  
            return response  
        elif 'message' in info:  
            note = Notification()  
            note.message = info.get('message')  
            return note  
        else:  
            raise Exception('unknown data when decode') 

另外,client和server的代码也要稍作调整。
整个过程如下:

#-*-coding=utf-8-*-

class Request(object):
    '''''
    @RPC请求,包含命令id和请求内容两部分。这个实现,与具体的RPC协议相关。
    @这里是简化起见,采用python自身的字典作为其协议的数据结构
    '''
    def __init__(self):
        '''''
        Constructor
        '''
        self.id=0#id的作用在于将Request和Response建立绑定关系.在异步调用时就有用
        self.command=None#sayHello
        self.parameter={}

    def __str__(self):
        return str({'id': self.id, 'command': self.command, 'parameter': self.parameter})

class Response(object):
    '''''
    @RPC回复。 包含答复id和执行结果两部分内容。其中答复id与对应的请求id一致。
    @简单起见,协议的实现使用python自家的字典
    '''
    def __init__(self):
        '''''
        Constructor
        '''
        self.id=0
        self.result=None

    def __str__(self):
        return str({'id': self.id, 'result': self.result})

class Client(object):
    def __init__(self):
        self.remote=None

    # @classmethod
    def encode(cls, message):
        if isinstance(message, Request):
            return str(message)
        elif isinstance(message, Response):
            return str(message)
        # elif isinstance(message, Notification):
        #     return str(message)
        else:
            raise Exception('unknown type when encode')

    # 内部是委托给远程remote对象来获取结果。
    def sayHello(self):
        req = Request()
        req.id = 1
        req.command = 'sayHello'
        return self.request(self.encode(req))

    def add(self, a, b):
        req = Request()
        req.id = 3
        req.command = 'add'
        req.parameter = {'a': a, 'b': b}
        return self.request(self.encode(req))

    def request(self,req):
        rsp=self.remote.procRequest(req)#将请求消息发送给远程服务端。但因为传输层这里还没实现,所以先暂时还是直接调用远端接口
        return rsp.result

class Server(object):
    def __init__(self):
        pass

    @classmethod
    def decode(cls, data):
        info = eval(data)
        if 'command' in info:
            request = Request()
            request.id = info.get('id')
            request.command = info.get('command')
            request.parameter = info.get('parameter', {})
            return request
        elif 'result' in info:
            response = Response()
            response.id = info.get('id')
            response.result = info.get('result')
            return response
        # elif 'message' in info:
        #     note = Notification()
        #     note.message = info.get('message')
        #     return note
        else:
            raise Exception('unknown data when decode')

    def sayHello(self):
        return 'Hello World'

    def add(self, a, b):
        return a + b

    def procRequest(self, data):
        req=self.decode(data)
        rsp = Response()
        rsp.id = req.id
        if req.command == 'sayHello':
            rsp.result = self.sayHello(**req.parameter)
        elif req.command == 'add':
            rsp.result = self.add(**req.parameter)
        else:
            raise Exception("unknown command")
        return rsp

if __name__=='__main__':
    server=Server()
    client=Client()
    client.remote=server
    print(client.sayHello())
    print(client.add(2,3))

结果如下:

Hello World
5

4.5、引入传输层

(原作者写的不全,现在还没把完整的还原出来代码,先放在这里,以后再补)
接下来处理数据传输。实际应用场景一般都是基于socket。socket代码比较多,使用起来也比较麻烦。而且具体的传输通道使用socket或者其他的方式,如更上层的http,或者android里的binder,都是可替换的,只是具体的一种实现而已。所以,这里我就偷个懒,只是引入一个很简单的Connection类,用来描述一下如何将数据传输 这一层给独立出来。
首先简单列出Connection类的实现,很简单,就是两个list,一个管发送,一个管接收。(实现没有考虑多线程安全,实际是必须考虑的)。
需要说明的是,这里的recv的实现约定是阻塞式的,也就是如果没有收到任何数据,recv调用会一直阻塞。

class Connection(object):
    ''''' 
    @RPC 连接。一般说来,都是socket连接,这里简化起见,直接本地变量实现。 
    '''

    def __init__(self, sending_msg_list, recving_msg_list):
        ''''' 
        Constructor 
        '''
        self.sending_msg_list = sending_msg_list
        self.recving_msg_list = recving_msg_list

    def send(self, message):
        self.sending_msg_list.append(message)

    def recv(self):
        while len(self.recving_msg_list) == 0: time.sleep(0.01)
        return self.recving_msg_list.pop(0)

    def isClosed(self):
        return False  

有了这个connection,剩下的就只要将rpc消息统统通过这个connection去发送,通过这个Connection去接收。

接着修改客户端的request请求,不再直接调用server端的procRequest方法,而是将请求交给connection,去发送。 然后等待connection收到server端的回复,将回复消息从connection中取出来。

def request(self, req):  
    # 全部简化处理,不考虑线程安全问题,不考虑异步  
    # 先是将RPC消息发送到服务端,然后服务端就会处理,并将结果发回到客户端,客户端这边接收处理结果。  
    # self.remote.procRequest(req) // 删除  
    self.conn.send(req)  
    rsp = self.conn.recv()  
    return rsp.result  

同样的,修改服务端收到request请求后的处理。首先反复调用connection.recv()方法读取客户端发过来的请求。当请求处理完成后,不再直接以函数返回的方式return,而是将rsp交给connection,由connection负责传输给client

# def procRequest(self, req): 调整参数列表,不再需要req  
def procRequest(self):
    # 循环读取并处理收到的客户端请求  
    while True:
        req = self.conn.recv()
        rsp = Response()
        rsp.id = req.id
        if req.command == 'sayHello':
            rsp.result = self.sayHello()
        elif req.command == 'whoAreYou':
            rsp.result = self.whoAreYou()
        else:
            raise Exception("unknown command")

            # return rsp  # rsp也是通过connection最终传给client,而不是直接函数返回  
        self.conn.send(rsp)

最后,列一下connection的初始化

slist = []
rlist = []
client = Client(Connection(slist, rlist))
server = Server(Connection(rlist, slist))
server.start()

总结,引入传输层的意义在于
1. 实现client与server端的解耦,client端不再需要持有server端的对象了。 这也是实现“远程调用 ”所必须的。
2. 传输层的实现有很大的自由度,一般说来,他无需关心具体的RPC消息的格式,只需要完成数据的可靠传输就可以了。
3. 传输层具体基于socket,binder, 是采用http,udp,tcp这些都是自由的,根据需要选择就可以了。也就是相当于一个可以自由拼接的组件。
4. 上面的模型实在过于简单,没有考虑多线程保护,没有考虑异常。实际比较理想的情况,应该起码有个类,Connector,以及Channel。其中channel只负责数据的传输,Connector负责管理channel。

4.6、引入服务注册机制

接下来处理RPC服务的注册机制。所谓注册机制,就是Server需要声明支持哪些rpc方法,然后当客户端发送调用某个声明的rpc方法之后,服务端能自动找到执行该请求的具体方法。以实际的例子为例,这是现在server端处理RPC请求的代码

def procRequest(self):
    # 循环读取并处理收到的客户端请求  
    while True:
        req = self.conn.recv()
        rsp = Response()
        rsp.id = req.id
        if req.command == 'sayHello':
            rsp.result = self.sayHello()
        elif req.command == 'whoAreYou':
            rsp.result = self.whoAreYou()
        else:
            raise Exception("unknown command")

        self.conn.send(rsp)  

上面的代码有一个很不好的地方,很难稳定。Server端每次新增一个支持的rpc方法,就要修改这个procRequest方法。有什么办法可以避免吗?有,就是引入服务注册机制。在这里,实际就是将command与具体的function object绑定起来,说穿了就是生成一个dict,

{‘sayHello’ : self.sayHello,     'whoAreYou': self.whoAreYou}。 

有这样的dict之后,收到req 之后,只要提取出command字段,然后从dict中找出对应的function,调用该function即可。
首先我们实现一个比较原始的服务注册机制。
这个实现很简单,self.services就是上面的dict。通过register()去注册服务,通过get_service()去获取服务名对应的function

class ServiceRegister(object):
    ''''' 
    @服务注册  不考虑线程安全,这里简化起见,也不引入反射机制。 
    '''

    def __init__(self):
        ''''' 
        Constructor 
        '''
        self.services = {}

        ## 注册具体的服务  

    #  @param servicename: 服务名  
    #  @param obj: 具体的对象  
    def register(self, obj, servicename):
        if servicename in self.services:
            print('warning: %s is already registered' % servicename)
        else:
            self.services[servicename] = obj

    def get_service(self, servicename):
        return self.services[servicename]

    def list_service(self, servicename=None):
        if servicename:
            return str({servicename, self.services[servicename]})
        else:
            return str(self.services)  

使用时,就是这个样子的
服务注册:

self.services.register(self.sayHello, 'Server.sayHello', )  
self.services.register(self.whoAreYou, 'Server.whoAreYou')  
self.services.register(self.add, 'Server.add')  

服务查找

def proc(self, req):
    rsp = Response()
    rsp.id = req.id
    rsp.result = ServiceCaller.call(self.services.get_service(req.command), req.parameter)
    ......

上面serviceCaller的实现,就是在RPC消息,实现带参数的RPC请求中,提到的 func(**args)的技巧

class ServiceCaller():
    def __init__(self):
        pass

    @classmethod
    def call(cls, caller, parameter):
        if not parameter or len(parameter) == 0:
            return caller()
        return caller(**parameter) 

下面我再引入一个自动注册服务的实现

class AutoServiceRegister(AbstractServiceRegister):  
    def register_class(self, obj, predicate=None):  
        if not (hasattr(obj, '__class__') and inspect.isclass(obj.__class__)):   
            return False  
        servicename = obj.__class__.__name__  
        for (name, attr) in inspect.getmembers(obj, predicate):  
            # 系统方法或者私有方法,不添加  
            if name.startswith('__') or name.startswith('_' + servicename + '__'): continue  
            #print(name)  
            if inspect.ismethod(attr): self.register_method(attr)  
            elif inspect.isfunction(attr): self.register_function(attr, servicename)  
        return True

使用

if __name__ == '__main__':
    class AServer(object):
        def __init__(self):
            pass

        def sayHello(self):
            return 'Hello World'

        def whoAreYou(self):
            return 'I am server'

        def __kaos(self):
            pass

        def _kaos(self):
            pass


    obj = AServer()

    service = AutoServiceRegister()
    print(service.register_class(obj))
    print(service.list_services())
    print(service.get_service('AServer.sayHello')) 

执行结果如下

True  
{'AServer': {'sayHello': <bound method AServer.sayHello of <__main__.AServer object at 0x000000000294EA90>>, 'whoAreYou': <bound method AServer.whoAreYou of <__main__.AServer object at 0x000000000294EA90>>, '_kaos': <bound method AServer._kaos of <__main__.AServer object at 0x000000000294EA90>>}}  
<bound method AServer.sayHello of <__main__.AServer object at 0x000000000294EA90>>  

详细说明 一下原理,利用了类似的反射的技术。有兴趣的同学可以先去了解一下inspect

  • register_class表示自动搜索一个类对象中的成员方法,并将其作为server端的rpc方法注册进去。
    以上面AServer为例, 会自动将sayHello, whoAreYou 这两个方法自动注册进来。同时像__init__, __kaos,_kaos之类的系统固有方法,或者私有方法,会自动剔除。
  • 注册时,传入的参数obj必须是class的instance,也就是类实例。虽然在python中,也支持类对象,但如果直接传递类对象,就会遇到如何初始化的难题。所以这里一致要求,必须是类的实例。
if not (hasattr(obj, '__class__') and inspect.isclass(obj.__class__)):   
    return False 

类实例的特点就是,包含__class__成员,而且__class__成员的值就是该类的类对象。inspect.isclass就是检测是不是类对象

  • inspect.getmembers()返回的是类对象的所有成员,包括系统固有方法以及私有方法
    所以,先要将系统方法和私有方法剔除,然后通过inspect,检查该成员是不是真的是function,就是可以被调用的。如果是,就注册进来
  • register_fucntion, register_method与普通的服务注册基本一样。就是添加(key,value)对。

总结:

  • 引入服务注册的方式也是为了代码解耦,将req的处理与具体的req消息内容解耦。
  • 上面我们 引入了两种服务注册的方式,一种方式是普通的方式,逐个添加方法。另一种方式通过python的“反射”技术,自动查找一个类里面的方法,并自动添加。
  • 方案还是很粗糙的,实际有很多优化的地方。

5、Java实现RPC框架

来源:

5.1、实现技术方案

下面使用比较原始的方案实现RPC框架,采用Socket通信、动态代理与反射与Java原生的序列化。

5.2、RPC框架架构

RPC架构分为三部分:

  • 1)服务提供者,运行在服务器端,提供服务接口定义与服务实现类。
  • 2)服务中心,运行在服务器端,负责将本地服务发布成远程服务,管理远程服务,提供给服务消费者使用。
  • 3)服务消费者,运行在客户端,通过远程代理对象调用远程服务。

5.3、 具体实现

服务提供者接口定义与实现,代码如下:

package com.rpcserver;

public interface HelloService {
    String sayHi(String name);
}

HelloServices接口实现类:

package com.rpcserver;

public class HelloServiceImpl implements HelloService {

    public String sayHi(String name) {
        // TODO Auto-generated method stub
        return "Hi, " + name;
    }

}

服务中心代码实现,代码如下:

package com.rpcserver;

import java.io.IOException;

public interface Server {
    public void stop();
    public void start() throws IOException;
    public void register(Class serviceInterface, Class impl);
    public boolean isRunning();
    public int getPort();
}

服务中心实现类:

package com.rpcserver;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Method;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.HashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ServiceCenter implements Server {

    private static ExecutorService executor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
    private static final HashMap < String, Class > serviceRegistry = new HashMap < String, Class > ();
    private static boolean isRunning = false;
    private static int port;

    public ServiceCenter(int port) {
        this.port = port;
    }

    public void stop() {
        isRunning = false;
        executor.shutdown();
    }

    public void start() throws IOException {
        ServerSocket server = new ServerSocket();
        server.bind(new InetSocketAddress(port));
        System.out.println("start server");
        try {
            while (true) {
                // 1.监听客户端的TCP连接,接到TCP连接后将其封装成task,由线程池执行
                executor.execute(new ServiceTask(server.accept()));
            }
        } finally {
            server.close();
        }
    }

    public void register(Class serviceInterface, Class impl) {
        serviceRegistry.put(serviceInterface.getName(), impl);
    }

    public boolean isRunning() {
        return isRunning;
    }

    public int getPort() {
        return port;
    }

    private static class ServiceTask implements Runnable {
        Socket clent = null;

        public ServiceTask(Socket client) {
            this.clent = client;
        }

        public void run() {
            ObjectInputStream input = null;
            ObjectOutputStream output = null;
            try {
                // 2.将客户端发送的码流反序列化成对象,反射调用服务实现者,获取执行结果
                input = new ObjectInputStream(clent.getInputStream());
                String serviceName = input.readUTF();
                String methodName = input.readUTF();
                Class <? > [] parameterTypes = (Class <? > []) input.readObject();
                Object[] arguments = (Object[]) input.readObject();
                Class serviceClass = serviceRegistry.get(serviceName);
                if (serviceClass == null) {
                    throw new ClassNotFoundException(serviceName + " not found");
                }
                Method method = serviceClass.getMethod(methodName, parameterTypes);
                Object result = method.invoke(serviceClass.newInstance(), arguments);

                // 3.将执行结果反序列化,通过socket发送给客户端
                output = new ObjectOutputStream(clent.getOutputStream());
                output.writeObject(result);
            } catch (Exception e) {
                e.printStackTrace();
            } finally {

                if (output != null) {
                    try {
                        output.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }

                if (input != null) {
                    try {
                        input.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }

                if (clent != null) {
                    try {
                        clent.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }

            }

        }

    }

}

客户端的远程代理对象:

package com.rpcserver;

import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.net.InetSocketAddress;
import java.net.Socket;

public class RPCClient < T > {

    public static < T > T getRemoteProxyObj(final Class <? > serviceInterface, final InetSocketAddress addr) {

        // 1.将本地的接口调用转换成JDK的动态代理,在动态代理中实现接口的远程调用
        return (T) Proxy.newProxyInstance(serviceInterface.getClassLoader(), new Class <? > [] {serviceInterface},
        new InvocationHandler() {
            public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                Socket socket = null;
                ObjectOutputStream output = null;
                ObjectInputStream input = null;
                try {
                    // 2.创建Socket客户端,根据指定地址连接远程服务提供者
                    socket = new Socket();
                    socket.connect(addr);
                    // 3.将远程服务调用所需的接口类、方法名、参数列表等编码后发送给服务提供者
                    output = new ObjectOutputStream(socket.getOutputStream());
                    output.writeUTF(serviceInterface.getName());
                    output.writeUTF(method.getName());
                    output.writeObject(method.getParameterTypes());
                    output.writeObject(args);
                    // 4.同步阻塞等待服务器返回应答,获取应答后返回
                    input = new ObjectInputStream(socket.getInputStream());

                    return input.readObject();
                } finally {
                    if (socket != null) socket.close();
                    if (output != null) output.close();
                    if (input != null) input.close();
                }
            }
        });

    }

}

最后为测试类(主程序):

package com.rpcserver;

import java.io.IOException;
import java.net.InetSocketAddress;

public class RPCTest {

    public static void main(String[] args) throws IOException {
        new Thread(new Runnable() {
            public void run() {
                try {
                    Server serviceServer = new ServiceCenter(8088);
                    serviceServer.register(HelloService.class, HelloServiceImpl.class);
                    serviceServer.start();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }

        }).start();

        HelloService service = RPCClient.getRemoteProxyObj(HelloService.class, new InetSocketAddress("localhost", 8088));
        System.out.println(service.sayHi("test"));

    }

}

运行结果如下:

start server
Hi, test

5.4、总结

RPC本质为消息处理模型,RPC屏蔽了底层不同主机间的通信细节,让进程调用远程的服务就像是本地的服务一样。

5.5、可以改进的地方

这里实现的简单RPC框架是使用Java语言开发,与Java语言高度耦合,并且通信方式采用的Socket是基于BIO实现的,IO效率不高,还有Java原生的序列化机制占内存太多,运行效率也不高。可以考虑从下面几种方法改进。

  • 1.可以采用基于JSON数据传输的RPC框架;
  • 2.可以使用NIO或直接使用Netty替代BIO实现;
  • 3.使用开源的序列化机制,如Hadoop Avro与Google protobuf等;
  • 4.服务注册可以使用Zookeeper进行管理,能够让应用更加稳定。

另外可参考:
简单的RPC java实现
自定义的RPC的Java实现
三百行代码完成一个简单的rpc框架

6、Python中实现远程调用(RPC、RMI)简单例子

远程调用一般分为两种,远程过程调用(RPC)和远程方法调用(RMI)。

6.1、RPC

RPC属于函数级别的远程调用,其多是通过HTTP传输数据,数据形式有XML、JSON、序列化数据等。在此,用python做一个xml-rpc的示例。 先给服务器端server.py

#-*- coding=uft-8- -*-

from SimpleXMLRPCServer import SimpleXMLRPCServer
def add(x, y):
    return x + y
if __name__ == '__main__':
    s = SimpleXMLRPCServer(('127.0.0.1', 8080))
    s.register_function(add)
    s.serve_forever()
#s是一个绑定了本地8080端口的服务器对象,register_function()方法将函数add注册到s中。serve_forever()启动服务器。 再给个客户端client.py:

客户端client.py

#-*- coding=uft-8- -*-

from xmlrpclib import ServerProxy
if __name__ == '__main__':
    s = ServerProxy("http://127.0.0.1:8080")
    print s.add(3,4)

现在,运行server.py,然后运行client.py,client.py所在的console会输出7。
从上可以看到,利用现有RPC框架来实现自己的远程过程调用还是很方便的,我们不用管信息发送接收实现,不用管编解码;我们只要专注于服务器端编写的自己的业务功能(就是一些功能函数),在服务器端配置好RPC服务器对象(如访问地址和端口),然后将我们的业务函数注册到服务器对象上,并启动服务器对象,此时,服务器端就一直运行,等待客户端的连接;客户端利用远程服务代理对象,利用这个代理对象我们可以利用服务器端的功能函数,就像利用本地的函数一样。

我们用wireshark看一下这期间传递的数据是什么样子的,请求的数据:

<?xml version='1.0' ?>
<methodCall>
    <methodName>
        add
    </methodName>
    <params>
        <param>
            <value>
                <int> 3 </int>
                </value>
        </param>
        <param>
            <value>
                <int> 4 </int>
            </value>
        </param>
    </params>
</methodCall>

响应的数据:

<?xml version='1.0' ?>
<methodResponse>
    <params>
        <param>
            <value>
                <int> 7 </int>
            </value>
        </param>
    </params>
</methodResponse>

其他python实现的RPC服务例子:
python 简单RPC示例

6.2、RMI

RMI意为远程方法调用,粒度比RPC要大,因为它的基本单位是对象。其大致思路是这样的:创建RMI服务器对象,将实例化的某个对象以指定的服务名称(也可以是多个对象,但是服务名称不应相同)注册到RMI服务器对象中,之后启动RMI服务器。服务器等待客户端发送的数据(包括服务名称、函数名、参数),将处理结果返回给客户端。 Pyro4是一个基于python的RMI实现,下面我们用Pyro4创建一个RMI服务器,请看server2.py:未验证

#-*-coding=utf-8-*-

import Pyro4
class GreetingMaker(object):
    def get_fortune(self, name):
        return "Hello, {0}. \n" .format(name)
greeting_maker=GreetingMaker()
daemon=Pyro4.Daemon()
uri=daemon.register(greeting_maker)
print("Ready. Object uri =", uri)
daemon.requestLoop()
#uri变量是Pyro4用自己的方法为greeting_maker对象生成的uri,其中包括套接字以及为greeting_maker生成的唯一的id。这个id相当于服务名称,当然也可以指定更易懂的服务名称。

下面是客户端client2.py:

#-*-coding=utf-8-*-

import Pyro4
uri=raw_input(" Pyro uri : ").strip()
name=raw_input("Your name: ").strip()
greeting_maker=Pyro4.Proxy(uri)
print(greeting_maker.get_fortune(name))

这其中要输入的uri也就是server2.py生成的uri。通过给Pyro4.Proxy传递greeting_maker的uri,可以认为和服务器端的greeting_maker建立的连接,然后调用greeting_makerget_fortune()方法。如果name是letian,那么print greeting_maker.get_fortune(name)的结果是Hello, letian.。

7、Netty介绍

Netty入门教程——认识Netty
Netty入门(一):零基础“HelloWorld”详细图文步骤
Netty 4.x User Guide 中文翻译《Netty 4.x 用户指南》
Essential Netty in Action 《Netty 实战(精髓)》
Netty官网

8、Jetty简介

Jetty官方文档翻译
Jetty:The Definitive Reference

补充知识

9、ServiceFramework学习

来源:allwefantasy/ServiceFramework

Java序列化与反序列化

来源
接口文档

Java序列化与反序列化

Java序列化是指把Java对象转换为字节序列的过程;而Java反序列化是指把字节序列恢复为Java对象的过程。

为什么需要序列化与反序列化

我们知道,当两个进程进行远程通信时,可以相互发送各种类型的数据,包括文本、图片、音频、视频等, 而这些数据都会以二进制序列的形式在网络上传送。那么当两个Java进程进行通信时,能否实现进程间的对象传送呢?答案是可以的。如何做到呢?这就需要Java序列化与反序列化了。换句话说,一方面,发送方需要把这个Java对象转换为字节序列,然后在网络上传送;另一方面,接收方需要从字节序列中恢复出Java对象。
当我们明晰了为什么需要Java序列化和反序列化后,我们很自然地会想Java序列化的好处。其好处一是实现了数据的持久化,通过序列化可以把数据永久地保存到硬盘上(通常存放在文件里),二是,利用序列化实现远程通信,即在网络上传送对象的字节序列。

如何实现Java序列化与反序列化

JDK类库中序列化API

java.io.ObjectOutputStream:表示对象输出流
它的writeObject(Object obj)方法可以对参数指定的obj对象进行序列化,把得到的字节序列写到一个目标输出流中。
java.io.ObjectInputStream:表示对象输入流
它的readObject()方法从输入流中读取字节序列,再把它们反序列化成为一个对象,并将其返回。

实现序列化的要求

只有实现了Serializable或Externalizable接口的类的对象才能被序列化,否则抛出异常。

实现Java对象序列化与反序列化的方法

假定一个Student类,它的对象需要序列化,可以有如下三种方法:
方法一:若Student类仅仅实现了Serializable接口,则可以按照以下方式进行序列化和反序列化
ObjectOutputStream采用默认的序列化方式,对Student对象的非transient的实例变量进行序列化。
ObjcetInputStream采用默认的反序列化方式,对对Student对象的非transient的实例变量进行反序列化。
方法二:若Student类仅仅实现了Serializable接口,并且还定义了readObject(ObjectInputStream in)和writeObject(ObjectOutputSteam out),则采用以下方式进行序列化与反序列化。
ObjectOutputStream调用Student对象的writeObject(ObjectOutputStream out)的方法进行序列化。
ObjectInputStream会调用Student对象的readObject(ObjectInputStream in)的方法进行反序列化。
方法三:若Student类实现了Externalnalizable接口,且Student类必须实现readExternal(ObjectInput in)和writeExternal(ObjectOutput out)方法,则按照以下方式进行序列化与反序列化。
ObjectOutputStream调用Student对象的writeExternal(ObjectOutput out))的方法进行序列化。
ObjectInputStream会调用Student对象的readExternal(ObjectInput in)的方法进行反序列化。

JDK类库中序列化的步骤

步骤一:创建一个对象输出流,它可以包装一个其它类型的目标输出流,如文件输出流:

ObjectOutputStream out = new ObjectOutputStream(new fileOutputStream(“D:\\objectfile.obj”));

需要一个文件来存放序列化的数据
步骤二:通过对象输出流的writeObject()方法写对象:

out.writeObject(“Hello”);
out.writeObject(new Date());

JDK类库中反序列化的步骤

步骤一:创建一个对象输入流,它可以包装一个其它类型输入流,如文件输入流:

ObjectInputStream in = new ObjectInputStream(new fileInputStream(“D:\\objectfile.obj”));

步骤二:通过对象输出流的readObject()方法读取对象:

String obj1 = (String)in.readObject();
Date obj2 = (Date)in.readObject();

说明:为了正确读取数据,完成反序列化,必须保证向对象输出流写对象的顺序与从对象输入流中读对象的顺序一致。

Java序列化与反序列化的例子

为了更好地理解Java序列化与反序列化,选择方法一编码实现。
Student类定义如下:

package com.jieke.io;  
import java.io.Serializable;  

/** 
 *Title:学生类 
 *Description:实现序列化接口的学生类 
 *Copyright: copyright(c) 2012 
 *Filename: Student.java 
 *@author Wang Luqing 
 *@version 1.0 
*/  
public class Student implements Serializable  
{  
    private String name;  
    private char sex;  
    private int year;  
    private double gpa;  

    public Student()  
    {  

    }  
    public Student(String name,char sex,int year,double gpa)  
    {  
        this.name = name;  
        this.sex = sex;  
        this.year = year;  
        this.gpa = gpa;  
    }  

    public void setName(String name)  
    {  
        this.name = name;  
    }  

    public void setSex(char sex)  
    {  
        this.sex = sex;  
    }  

    public void setYear(int year)  
    {  
        this.year = year;  
    }  

    public void setGpa(double gpa)  
    {  
        this.gpa = gpa;  
    }  

    public String getName()  
    {  
        return this.name;  
    }  

    public char getSex()  
    {  
        return this.sex;  
    }  

    public int getYear()  
    {  
        return this.year;  
    }  

    public double getGpa()  
    {  
        return this.gpa;  
    }  
}  

把Student类的对象序列化到文件O:\\Java\\com\\jieke\\io\\student.txt,并从该文件中反序列化,向console显示结果。代码如下:

import java.io.*;  

/** 
 *Title:应用学生类 
 *Description:实现学生类实例的序列化与反序列化 
 *Copyright: copyright(c) 2012 
 *Filename: UseStudent.java 
 *@author Wang Luqing 
 *@version 1.0 
*/  

public class UseStudent  
{  
    public static void main(String[] args)  
    {  
        Student st = new Student("Tom",'M',20,3.6);  
        File file = new File("O:\\Java\\com\\jieke\\io\\student.txt");  
        try  
        {  
            file.createNewFile();  
        }  
        catch(IOException e)  
        {  
            e.printStackTrace();  
        }  
        try  
        {  
            //Student对象序列化过程  
            FileOutputStream fos = new FileOutputStream(file);  
            ObjectOutputStream oos = new ObjectOutputStream(fos);  
            oos.writeObject(st);  
            oos.flush();  
            oos.close();  
            fos.close();  

            //Student对象反序列化过程  
            FileInputStream fis = new FileInputStream(file);  
            ObjectInputStream ois = new ObjectInputStream(fis);  
            Student st1 = (Student) ois.readObject();  
            System.out.println("name = " + st1.getName());  
            System.out.println("sex = " + st1.getSex());  
            System.out.println("year = " + st1.getYear());  
            System.out.println("gpa = " + st1.getGpa());  
            ois.close();  
            fis.close();  
        }  
        catch(ClassNotFoundException e)  
        {  
            e.printStackTrace();  
        }  
        catch (IOException e)  
        {  
            e.printStackTrace();  
        }               
    }  
}  

结果如下所示:

name = Tom
sex = M
year = 20
gpa = 3.6

总结:

1)Java序列化就是把对象转换成字节序列,而Java反序列化就是把字节序列还原成Java对象。
2)采用Java序列化与反序列化技术,一是可以实现数据的持久化,在MVC模式中很是有用;二是可以对象数据的远程通信。

序列化和反序列化

来源:刘丁 ·2015-02-26 10:00

摘要

序列化和反序列化几乎是工程师们每天都要面对的事情,但是要精确掌握这两个概念并不容易:一方面,它们往往作为框架的一部分出现而湮没在框架之中;另一方面,它们会以其他更容易理解的概念出现,例如加密、持久化。然而,序列化和反序列化的选型却是系统设计或重构一个重要的环节,在分布式、大数据量系统设计里面更为显著。恰当的序列化协议不仅可以提高系统的通用性、强健性、安全性、优化系统性能,而且会让系统更加易于调试、便于扩展。本文从多个角度去分析和讲解“序列化和反序列化”,并对比了当前流行的几种序列化协议,期望对读者做序列化选型有所帮助。

简介

文章作者服务于美团推荐与个性化组,该组致力于为美团用户提供每天billion级别的高质量个性化推荐以及排序服务。从Terabyte级别的用户行为数据,到Gigabyte级别的Deal/Poi数据;从对实时性要求毫秒以内的用户实时地理位置数据,到定期后台job数据,推荐与重排序系统需要多种类型的数据服务。推荐与重排序系统客户包括各种内部服务、美团客户端、美团网站。为了提供高质量的数据服务,为了实现与上下游各系统进行良好的对接,序列化和反序列化的选型往往是我们做系统设计的一个重要考虑因素。
本文内容按如下方式组织:

  • 第一部分给出了序列化和反序列化的定义,以及其在通讯协议中所处的位置。
  • 第二部分从使用者的角度探讨了序列化协议的一些特性。
  • 第三部分描述在具体的实施过程中典型的序列化组件,并与数据库组建进行了类比。
  • 第四部分分别讲解了目前常见的几种序列化协议的特性,应用场景,并对相关组件进行举例。
  • 最后一部分,基于各种协议的特性,以及相关benchmark数据,给出了作者的技术选型建议。

一、定义以及相关概念

互联网的产生带来了机器间通讯的需求,而互联通讯的双方需要采用约定的协议,序列化和反序列化属于通讯协议的一部分。通讯协议往往采用分层模型,不同模型每层的功能定义以及颗粒度不同,例如:TCP/IP协议是一个四层协议,而OSI模型却是七层协议模型。在OSI七层协议模型中展现层(Presentation Layer)的主要功能是把应用层的对象转换成一段连续的二进制串,或者反过来,把二进制串转换成应用层的对象–这两个功能就是序列化和反序列化。一般而言,TCP/IP协议的应用层对应与OSI七层协议模型的应用层,展示层和会话层,所以序列化协议属于TCP/IP协议应用层的一部分。本文对序列化协议的讲解主要基于OSI七层协议模型。

  • 序列化: 将数据结构或对象转换成二进制串的过程
  • 反序列化:将在序列化过程中所生成的二进制串转换成数据结构或者对象的过程

数据结构、对象与二进制串
不同的计算机语言中,数据结构,对象以及二进制串的表示方式并不相同。

数据结构和对象:对于类似Java这种完全面向对象的语言,工程师所操作的一切都是对象(Object),来自于类的实例化。在Java语言中最接近数据结构的概念,就是POJO(Plain Old Java Object)或者Javabean--那些只有setter/getter方法的类。而在C++这种半面向对象的语言中,数据结构和struct对应,对象和class对应。

二进制串:序列化所生成的二进制串指的是存储在内存中的一块数据。C++语言具有内存操作符,所以二进制串的概念容易理解,例如,C++语言的字符串可以直接被传输层使用,因为其本质上就是以’\0’结尾的存储在内存中的二进制串。在Java语言里面,二进制串的概念容易和String混淆。实际上String 是Java的一等公民,是一种特殊对象(Object)。对于跨语言间的通讯,序列化后的数据当然不能是某种语言的特殊数据类型。二进制串在Java里面所指的是byte[],byte是Java的8种原生数据类型之一(Primitive data types)。

二、序列化协议特性

每种序列化协议都有优点和缺点,它们在设计之初有自己独特的应用场景。在系统设计的过程中,需要考虑序列化需求的方方面面,综合对比各种序列化协议的特性,最终给出一个折衷的方案。
通用性
通用性有两个层面的意义:
第一、技术层面,序列化协议是否支持跨平台、跨语言。如果不支持,在技术层面上的通用性就大大降低了。
第二、流行程度,序列化和反序列化需要多方参与,很少人使用的协议往往意味着昂贵的学习成本;另一方面,流行度低的协议,往往缺乏稳定而成熟的跨语言、跨平台的公共包。
强健性/鲁棒性
以下两个方面的原因会导致协议不够强健:
第一、成熟度不够,一个协议从制定到实施,到最后成熟往往是一个漫长的阶段。协议的强健性依赖于大量而全面的测试,对于致力于提供高质量服务的系统,采用处于测试阶段的序列化协议会带来很高的风险。
第二、语言/平台的不公平性。为了支持跨语言、跨平台的功能,序列化协议的制定者需要做大量的工作;但是,当所支持的语言或者平台之间存在难以调和的特性的时候,协议制定者需要做一个艰难的决定–支持更多人使用的语言/平台,亦或支持更多的语言/平台而放弃某个特性。当协议的制定者决定为某种语言或平台提供更多支持的时候,对于使用者而言,协议的强健性就被牺牲了。
可调试性/可读性
序列化和反序列化的数据正确性和业务正确性的调试往往需要很长的时间,良好的调试机制会大大提高开发效率。序列化后的二进制串往往不具备人眼可读性,为了验证序列化结果的正确性,写入方不得同时撰写反序列化程序,或提供一个查询平台–这比较费时;另一方面,如果读取方未能成功实现反序列化,这将给问题查找带来了很大的挑战–难以定位是由于自身的反序列化程序的bug所导致还是由于写入方序列化后的错误数据所导致。对于跨公司间的调试,由于以下原因,问题会显得更严重:
第一、支持不到位,跨公司调试在问题出现后可能得不到及时的支持,这大大延长了调试周期。
第二、访问限制,调试阶段的查询平台未必对外公开,这增加了读取方的验证难度。

如果序列化后的数据人眼可读,这将大大提高调试效率, XML和JSON就具有人眼可读的优点。
性能
性能包括两个方面,时间复杂度和空间复杂度:
第一、空间开销(Verbosity), 序列化需要在原有的数据上加上描述字段,以为反序列化解析之用。如果序列化过程引入的额外开销过高,可能会导致过大的网络,磁盘等各方面的压力。对于海量分布式存储系统,数据量往往以TB为单位,巨大的的额外空间开销意味着高昂的成本。
第二、时间开销(Complexity),复杂的序列化协议会导致较长的解析时间,这可能会使得序列化和反序列化阶段成为整个系统的瓶颈。
可扩展性/兼容性
移动互联时代,业务系统需求的更新周期变得更快,新的需求不断涌现,而老的系统还是需要继续维护。如果序列化协议具有良好的可扩展性,支持自动增加新的业务字段,而不影响老的服务,这将大大提供系统的灵活度。
安全性/访问限制
在序列化选型的过程中,安全性的考虑往往发生在跨局域网访问的场景。当通讯发生在公司之间或者跨机房的时候,出于安全的考虑,对于跨局域网的访问往往被限制为基于HTTP/HTTPS的80和443端口。如果使用的序列化协议没有兼容而成熟的HTTP传输层框架支持,可能会导致以下三种结果之一:
第一、因为访问限制而降低服务可用性。
第二、被迫重新实现安全协议而导致实施成本大大提高。
第三、开放更多的防火墙端口和协议访问,而牺牲安全性。

三、序列化和反序列化的组件

典型的序列化和反序列化过程往往需要如下组件:

  • IDL(Interface description language)文件:参与通讯的各方需要对通讯的内容需要做相关的约定(Specifications)。为了建立一个与语言和平台无关的约定,这个约定需要采用与具体开发语言、平台无关的语言来进行描述。这种语言被称为接口描述语言(IDL),采用IDL撰写的协议约定称之为IDL文件。
  • IDL Compiler:IDL文件中约定的内容为了在各语言和平台可见,需要有一个编译器,将IDL文件转换成各语言对应的动态库。
  • Stub/Skeleton Lib:负责序列化和反序列化的工作代码。Stub是一段部署在分布式系统客户端的代码,一方面接收应用层的参数,并对其序列化后通过底层协议栈发送到服务端,另一方面接收服务端序列化后的结果数据,反序列化后交给客户端应用层;Skeleton部署在服务端,其功能与Stub相反,从传输层接收序列化参数,反序列化后交给服务端应用层,并将应用层的执行结果序列化后最终传送给客户端Stub。
  • Client/Server:指的是应用层程序代码,他们面对的是IDL所生存的特定语言的class或struct。
  • 底层协议栈和互联网:序列化之后的数据通过底层的传输层、网络层、链路层以及物理层协议转换成数字信号在互联网中传递。
    这里写图片描述

序列化组件与数据库访问组件的对比
数据库访问对于很多工程师来说相对熟悉,所用到的组件也相对容易理解。下表类比了序列化过程中用到的部分组件和数据库访问组件的对应关系,以便于大家更好的把握序列化相关组件的概念。

序列化组件 数据库组件 说明
IDL DDL 用于建表或者模型的语言
DL file DB Schema 表创建文件或模型文件
Stub/Skeleton lib O/R mapping 将class和Table或者数据模型进行映射

四、几种常见的序列化和反序列化协议

互联网早期的序列化协议主要有COM和CORBA。

COM主要用于Windows平台,并没有真正实现跨平台,另外COM的序列化的原理利用了编译器中虚表,使得其学习成本巨大(想一下这个场景, 工程师需要是简单的序列化协议,但却要先掌握语言编译器)。由于序列化的数据与编译器紧耦合,扩展属性非常麻烦。

CORBA是早期比较好的实现了跨平台,跨语言的序列化协议。COBRA的主要问题是参与方过多带来的版本过多,版本之间兼容性较差,以及使用复杂晦涩。这些政治经济,技术实现以及早期设计不成熟的问题,最终导致COBRA的渐渐消亡。J2SE 1.3之后的版本提供了基于CORBA协议的RMI-IIOP技术,这使得Java开发者可以采用纯粹的Java语言进行CORBA的开发。

这里主要介绍和对比几种当下比较流行的序列化协议,包括XML、JSON、Protobuf、Thrift和Avro。

一个例子

如前所述,序列化和反序列化的出现往往晦涩而隐蔽,与其他概念之间往往相互包容。为了更好了让大家理解序列化和反序列化的相关概念在每种协议里面的具体实现,我们将一个例子穿插在各种序列化协议讲解中。在该例子中,我们希望将一个用户信息在多个系统里面进行传递;在应用层,如果采用Java语言,所面对的类对象如下所示:

class Address
{
    private String city;
    private String postcode;
    private String street;
}
public class UserInfo
{
    private Integer userid;
    private String name;
    private List<Address> address;
}

XML&SOAP

XML是一种常用的序列化和反序列化协议,具有跨机器,跨语言等优点。 XML历史悠久,其1.0版本早在1998年就形成标准,并被广泛使用至今。XML的最初产生目标是对互联网文档(Document)进行标记,所以它的设计理念中就包含了对于人和机器都具备可读性。 但是,当这种标记文档的设计被用来序列化对象的时候,就显得冗长而复杂(Verbose and Complex)。 XML本质上是一种描述语言,并且具有自我描述(Self-describing)的属性,所以XML自身就被用于XML序列化的IDL。 标准的XML描述格式有两种:DTD(Document Type Definition)和XSD(XML Schema Definition)。作为一种人眼可读(Human-readable)的描述语言,XML被广泛使用在配置文件中,例如O/R mapping、 Spring Bean Configuration File 等。

SOAP(Simple Object Access protocol) 是一种被广泛应用的,基于XML为序列化和反序列化协议的结构化消息传递协议。SOAP在互联网影响如此大,以至于我们给基于SOAP的解决方案一个特定的名称–Web service。SOAP虽然可以支持多种传输层协议,不过SOAP最常见的使用方式还是XML+HTTP。SOAP协议的主要接口描述语言(IDL)是WSDL(Web Service Description Language)。SOAP具有安全、可扩展、跨语言、跨平台并支持多种传输层协议。如果不考虑跨平台和跨语言的需求,XML的在某些语言里面具有非常简单易用的序列化使用方法,无需IDL文件和第三方编译器, 例如Java+XStream。
自我描述与递归

SOAP是一种采用XML进行序列化和反序列化的协议,它的IDL是WSDL. 而WSDL的描述文件是XSD,而XSD自身是一种XML文件。 这里产生了一种有趣的在数学上称之为“递归”的问题,这种现象往往发生在一些具有自我属性(Self-description)的事物上。
IDL文件举例

采用WSDL描述上述用户基本信息的例子如下:

<xsd:complexType name='Address'>
     <xsd:attribute name='city' type='xsd:string' />
     <xsd:attribute name='postcode' type='xsd:string' />
     <xsd:attribute name='street' type='xsd:string' />
</xsd:complexType>
<xsd:complexType name='UserInfo'>
     <xsd:sequence>
     <xsd:element name='address' type='tns:Address'/>
     <xsd:element name='address1' type='tns:Address'/> 
     </xsd:sequence>
     <xsd:attribute name='userid' type='xsd:int' />
     <xsd:attribute name='name' type='xsd:string' /> 
</xsd:complexType>

典型应用场景和非应用场景

SOAP协议具有广泛的群众基础,基于HTTP的传输协议使得其在穿越防火墙时具有良好安全特性,XML所具有的人眼可读(Human-readable)特性使得其具有出众的可调试性,互联网带宽的日益剧增也大大弥补了其空间开销大(Verbose)的缺点。对于在公司之间传输数据量相对小或者实时性要求相对低(例如秒级别)的服务是一个好的选择。

由于XML的额外空间开销大,序列化之后的数据量剧增,对于数据量巨大序列持久化应用常景,这意味着巨大的内存和磁盘开销,不太适合XML。另外,XML的序列化和反序列化的空间和时间开销都比较大,对于对性能要求在ms级别的服务,不推荐使用。WSDL虽然具备了描述对象的能力,SOAP的S代表的也是simple,但是SOAP的使用绝对不简单。对于习惯于面向对象编程的用户,WSDL文件不直观。

JSON(Javascript Object Notation)

JSON起源于弱类型语言Javascript, 它的产生来自于一种称之为”Associative array”的概念,其本质是就是采用”Attribute-value”的方式来描述对象。实际上在Javascript和PHP等弱类型语言中,类的描述方式就是Associative array。JSON的如下优点,使得它快速成为最广泛使用的序列化协议之一:
1、这种Associative array格式非常符合工程师对对象的理解。
2、它保持了XML的人眼可读(Human-readable)的优点。
3、相对于XML而言,序列化后的数据更加简洁。 来自于的以下链接的研究表明:XML所产生序列化之后文件的大小接近JSON的两倍。http://www.codeproject.com/Articles/604720/JSON-vs-XML-Some-hard-numbers-about-verbosity
4、它具备Javascript的先天性支持,所以被广泛应用于Web browser的应用常景中,是Ajax的事实标准协议。
5、与XML相比,其协议比较简单,解析速度比较快。
6、松散的Associative array使得其具有良好的可扩展性和兼容性。
IDL悖论

JSON实在是太简单了,或者说太像各种语言里面的类了,所以采用JSON进行序列化不需要IDL。这实在是太神奇了,存在一种天然的序列化协议,自身就实现了跨语言和跨平台。然而事实没有那么神奇,之所以产生这种假象,来自于两个原因:
第一、Associative array在弱类型语言里面就是类的概念,在PHP和Javascript里面Associative array就是其class的实际实现方式,所以在这些弱类型语言里面,JSON得到了非常良好的支持。
第二、IDL的目的是撰写IDL文件,而IDL文件被IDL Compiler编译后能够产生一些代码(Stub/Skeleton),而这些代码是真正负责相应的序列化和反序列化工作的组件。 但是由于Associative array和一般语言里面的class太像了,他们之间形成了一一对应关系,这就使得我们可以采用一套标准的代码进行相应的转化。对于自身支持Associative array的弱类型语言,语言自身就具备操作JSON序列化后的数据的能力;对于Java这强类型语言,可以采用反射的方式统一解决,例如Google提供的Gson。
典型应用场景和非应用场景

JSON在很多应用场景中可以替代XML,更简洁并且解析速度更快。典型应用场景包括:
1、公司之间传输数据量相对小,实时性要求相对低(例如秒级别)的服务。
2、基于Web browser的Ajax请求。
3、由于JSON具有非常强的前后兼容性,对于接口经常发生变化,并对可调式性要求高的场景,例如Mobile app与服务端的通讯。
4、由于JSON的典型应用场景是JSON+HTTP,适合跨防火墙访问。

总的来说,采用JSON进行序列化的额外空间开销比较大,对于大数据量服务或持久化,这意味着巨大的内存和磁盘开销,这种场景不适合。没有统一可用的IDL降低了对参与方的约束,实际操作中往往只能采用文档方式来进行约定,这可能会给调试带来一些不便,延长开发周期。 由于JSON在一些语言中的序列化和反序列化需要采用反射机制,所以在性能要求为ms级别,不建议使用。
IDL文件举例

以下是UserInfo序列化之后的一个例子:

{"userid":1,"name":"messi","address":[{"city":"北京","postcode":"1000000","street":"wangjingdonglu"}]}

Thrift

Thrift是Facebook开源提供的一个高性能,轻量级RPC服务框架,其产生正是为了满足当前大数据量、分布式、跨语言、跨平台数据通讯的需求。 但是,Thrift并不仅仅是序列化协议,而是一个RPC框架。相对于JSON和XML而言,Thrift在空间开销和解析性能上有了比较大的提升,对于对性能要求比较高的分布式系统,它是一个优秀的RPC解决方案;但是由于Thrift的序列化被嵌入到Thrift框架里面,Thrift框架本身并没有透出序列化和反序列化接口,这导致其很难和其他传输层协议共同使用(例如HTTP)。
典型应用场景和非应用场景

对于需求为高性能,分布式的RPC服务,Thrift是一个优秀的解决方案。它支持众多语言和丰富的数据类型,并对于数据字段的增删具有较强的兼容性。所以非常适用于作为公司内部的面向服务构建(SOA)的标准RPC框架。

不过Thrift的文档相对比较缺乏,目前使用的群众基础相对较少。另外由于其Server是基于自身的Socket服务,所以在跨防火墙访问时,安全是一个顾虑,所以在公司间进行通讯时需要谨慎。 另外Thrift序列化之后的数据是Binary数组,不具有可读性,调试代码时相对困难。最后,由于Thrift的序列化和框架紧耦合,无法支持向持久层直接读写数据,所以不适合做数据持久化序列化协议。
IDL文件举例

struct Address
{ 
    1: required string city;
    2: optional string postcode;
    3: optional string street;
} 
struct UserInfo
{ 
    1: required string userid;
    2: required i32 name;
    3: optional list<Address> address;
}

Protobuf

Protobuf具备了优秀的序列化协议的所需的众多典型特征:
1、标准的IDL和IDL编译器,这使得其对工程师非常友好。
2、序列化数据非常简洁,紧凑,与XML相比,其序列化之后的数据量约为1/3到1/10。
3、解析速度非常快,比对应的XML快约20-100倍。
4、提供了非常友好的动态库,使用非常简介,反序列化只需要一行代码。

Protobuf是一个纯粹的展示层协议,可以和各种传输层协议一起使用;Protobuf的文档也非常完善。 但是由于Protobuf产生于Google,所以目前其仅仅支持Java、C++、Python三种语言。另外Protobuf支持的数据类型相对较少,不支持常量类型。由于其设计的理念是纯粹的展现层协议(Presentation Layer),目前并没有一个专门支持Protobuf的RPC框架。
典型应用场景和非应用场景

Protobuf具有广泛的用户基础,空间开销小以及高解析性能是其亮点,非常适合于公司内部的对性能要求高的RPC调用。由于Protobuf提供了标准的IDL以及对应的编译器,其IDL文件是参与各方的非常强的业务约束,另外,Protobuf与传输层无关,采用HTTP具有良好的跨防火墙的访问属性,所以Protobuf也适用于公司间对性能要求比较高的场景。由于其解析性能高,序列化后数据量相对少,非常适合应用层对象的持久化场景。

它的主要问题在于其所支持的语言相对较少,另外由于没有绑定的标准底层传输层协议,在公司间进行传输层协议的调试工作相对麻烦。
IDL文件举例

message Address
{
    required string city=1;
    optional string postcode=2;
    optional string street=3;
}
message UserInfo
{
    required string userid=1;
    required string name=2;
    repeated Address address=3;
}

Avro

Avro的产生解决了JSON的冗长和没有IDL的问题,Avro属于Apache Hadoop的一个子项目。 Avro提供两种序列化格式:JSON格式或者Binary格式。Binary格式在空间开销和解析性能方面可以和Protobuf媲美,JSON格式方便测试阶段的调试。 Avro支持的数据类型非常丰富,包括C++语言里面的union类型。Avro支持JSON格式的IDL和类似于Thrift和Protobuf的IDL(实验阶段),这两者之间可以互转。Schema可以在传输数据的同时发送,加上JSON的自我描述属性,这使得Avro非常适合动态类型语言。 Avro在做文件持久化的时候,一般会和Schema一起存储,所以Avro序列化文件自身具有自我描述属性,所以非常适合于做Hive、Pig和MapReduce的持久化数据格式。对于不同版本的Schema,在进行RPC调用的时候,服务端和客户端可以在握手阶段对Schema进行互相确认,大大提高了最终的数据解析速度。

典型应用场景和非应用场景

Avro解析性能高并且序列化之后的数据非常简洁,比较适合于高性能的序列化服务。

由于Avro目前非JSON格式的IDL处于实验阶段,而JSON格式的IDL对于习惯于静态类型语言的工程师来说不直观。

IDL文件举例

protocol Userservice {
  record Address {
   string city;
   string postcode;
   string street;
  }  
  record UserInfo {
   string name;
   int userid;
   array<Address> address = [];
  }
}

所对应的JSON Schema格式如下:

{
  "protocol" : "Userservice",
  "namespace" : "org.apache.avro.ipc.specific",
  "version" : "1.0.5",
  "types" : [ {
    "type" : "record",
    "name" : "Address",
    "fields" : [ {
      "name" : "city",
      "type" : "string"
    }, {
      "name" : "postcode",
      "type" : "string"
    }, {
      "name" : "street",
      "type" : "string"
    } ]
  }, {
    "type" : "record",
    "name" : "UserInfo",
    "fields" : [ {
      "name" : "name",
      "type" : "string"
    }, {
      "name" : "userid",
      "type" : "int"
    }, {
      "name" : "address",
      "type" : {
        "type" : "array",
        "items" : "Address"
      },
      "default" : [ ]
    } ]
  } ],
  "messages" : { }
}

五、Benchmark以及选型建议

Benchmark

以下数据来自https://code.google.com/p/thrift-protobuf-compare/wiki/Benchmarking
解析性能
这里写图片描述
序列化之空间开销
这里写图片描述
从上图可得出如下结论:
1、XML序列化(Xstream)无论在性能和简洁性上比较差。
2、Thrift与Protobuf相比在时空开销方面都有一定的劣势。
3、Protobuf和Avro在两方面表现都非常优越。
选型建议
以上描述的五种序列化和反序列化协议都各自具有相应的特点,适用于不同的场景:
1、对于公司间的系统调用,如果性能要求在100ms以上的服务,基于XML的SOAP协议是一个值得考虑的方案。
2、基于Web browser的Ajax,以及Mobile app与服务端之间的通讯,JSON协议是首选。对于性能要求不太高,或者以动态类型语言为主,或者传输数据载荷很小的的运用场景,JSON也是非常不错的选择。
3、对于调试环境比较恶劣的场景,采用JSON或XML能够极大的提高调试效率,降低系统开发成本。
4、当对性能和简洁性有极高要求的场景,Protobuf,Thrift,Avro之间具有一定的竞争关系。
5、对于T级别的数据的持久化应用场景,Protobuf和Avro是首要选择。如果持久化后的数据存储在Hadoop子项目里,Avro会是更好的选择。
6、由于Avro的设计理念偏向于动态类型语言,对于动态语言为主的应用场景,Avro是更好的选择。
7、对于持久层非Hadoop项目,以静态类型语言为主的应用场景,Protobuf会更符合静态类型语言工程师的开发习惯。
8、如果需要提供一个完整的RPC解决方案,Thrift是一个好的选择。
9、如果序列化之后需要支持不同的传输层协议,或者需要跨防火墙访问的高性能场景,Protobuf可以优先考虑。
参考文献:
http://www.codeproject.com/Articles/604720/JSON-vs-XML-Some-hard-numbers-about-verbosity
https://code.google.com/p/thrift-protobuf-compare/wiki/Benchmarking
http://en.wikipedia.org/wiki/Serialization
http://en.wikipedia.org/wiki/Soap
http://en.wikipedia.org/wiki/XML
http://en.wikipedia.org/wiki/JSON
http://avro.apache.org/
http://www.oracle.com/technetwork/java/rmi-iiop-139743.html

猜你喜欢

转载自blog.csdn.net/qingqing7/article/details/80112629
今日推荐