更多腾讯海量技术文章,请关注云加社区:https://cloud.tencent.com/developer
作者:韩伟
远程对象调用的概念
要说“远程对象”,必先说“远程调用”,也就是RPC。比较著名的RPC框架有,最近很火的gRPC,也就是Google开源的RPC。另外还有Facebook开源的Thrift等等……我厂内部也有很多RPC框架,琳琅满目不暇接。Java在JDK里面也支持RMI(Remote Method Invoke: 远程方法请求)功能,也可以视为一种RPC,但实际上这个更像我们现在要讨论的“远程对象调用”。
在诸多的RPC中,我们都基本认为是通过网络,对运行在另外一个进程(或者电脑)里的某个函数,发起一次调用请求。既然是一次函数调用,那么我们自然要传入参数,然后期望获得返回值。在这个过程中,我们往往只需要输入:函数名+参数,RPC就能找到一个远程的进程,去执行对应的函数,然后传入目标参数。在这个过程里,执行这个函数的进程,会被认为是无状态的,所有的输出,都仅与输入的参数有关,除非有一部分状态是记录在数据库(持久化设备)上的。因此,计算的过程(算法),和计算的数据,实际上分离的,这些计算所需的数据,要么来源于参数,要么是数据库设备。而被请求的函数,以及装载这个函数的容器——进程,是不保证任何的状态维护能力的。
而“远程对象调用”,正是在“状态”这个环节上,和RPC不同——它是由框架去保证某种状态的。当我们发起一个远程对象调用的时候,是需要首先“找到”一个远程对象,然后再发起“方法”(成员函数)调用。这和RPC就产生了两个明显的区别:
一、我们需要用某种手段定位到对象,而不是仅仅用一个函数名。对象是一个更复杂的远程概念,因为有可能同属于一个类(class),而存在多个状态一致或不一致的对象,在远程的机器上存在。我们就不能仅仅通过一个固定的路由标志(比如类名)去找一个这样的对象。远程对象的路由方式成为不同“远程对象调用”框架之间的一个显著区别。
二、我们并不需要把所有的数据,在每次请求时都通过参数发给远程对象,因为对于同一个远程对象来说,它是可以包含大量过程状态的。我们只要找到正确的远程对象,就能获得之前操作所造成的结果状态。有远程对象往往是生存在进程的内存中,所以对于访问自己的状态数据,会非常快速,这对于有延迟压力的程序来说,是非常有用的。
所以,远程对象调用,最大的特点,就是数据和计算是合并在一起的——这很好的提高了使用面向对象编程的便利性,也大大降低了远程调用中因为数据拉取产生的延迟。
远程对象的优点:DB压力、易用性
在传统的“请求-响应”为基础的分布式服务器中,最常见的数据系统是:接入-逻辑-缓存-数据库 这样一个四层结构。为了让承担计算压力的“逻辑”模块能分布到不同的进程上,我们往往会把“逻辑”模块做成“无状态”的,这样我们就可以随意的启动、停止任何一个逻辑模块的进程,而不需要担心因此丢失用户数据。但是这样做,逻辑模块是轻松了,承担状态存储的“缓存-数据库”哥俩压力就大了。因为每一个数据操作,都需要去从他们这里读取数据,然后再回写结果(如果有数据修改操作的话)。
由于“缓存-数据库”模块是有状态的,一般来说还很难简单的做分布式部署,因为如果随机分布数据的话,逻辑模块可能就会找不到状态所在的缓存进程。从CAP理论可以知道,我们要让状态能分布,就一定要牺牲一些一致性或可用性。因此我们更倾向以NoSQL的存储系统去充当“缓存-数据库”模块。但是,即便是NoSQL,还是会有两个缺点:一个是跨进程访问的延迟;一个是编程上的复杂性。
跨进程访问的延迟来源于两方面,一方面是本身跨进程通过socket之类的手段通讯,就会有比进程内存访问高的多的延迟,而且我们常常会把一个业务流程按数据的类型划分到不同的“逻辑模块”里,这样一个业务请求可能会需要多次的跨进程访问才能访问完所需的数据,这就大大加重了因为网络带来的延迟;另外一方面来源于路由查找,虽然我们可以用一致性哈希这类算法取代路由查找,但是基于数据的业务特性,我们却不太喜欢把所有数据都拆的七零八落,所以常常还是有一个查询、或探索数据所在地的过程。
编程的复杂性也是很严重的问题。不管是SQL还是NoSQL,这些数据都是以序列化的方式描述的,并且也按照数据的组织(存放)形式,要求使用者去准备好输入或者解析读出这些数据。这些数据和我们在编程中常用的结构体、对象往往完全是不一样的形式。这就造成了我们很多额外的编码和调试的工作。这些数据往往还是“结构敏感”的:如果我们修改了数据结构,往往需要重新配置数据表结构,修改访问代码等等。这让我们在快速开发业务逻辑的时候,背上沉重的开发效率包袱。——因此业界才有很多所谓ORM(对象关系映射)的框架出现。
但是如果我们使用“远程对象调用”,就可以有效的缓解以上两个问题:
一、缓解跨进程延迟。由于远程对象本身已经包含了数据,所以对于所需的数据,都是从内存中直接读写,这方面的延迟是绝对最快的。另外,由于远程对象调用发起之前,已经需要先查找到目地对象,这样就把查找方法和查找数据的两个过程合二为一了,在路由层面也能有效降低延迟。
二、极好的易用性。由于面向对象编程的概念已经深入人心,所以对于“先找到一个对象”,然后“调用其方法”的过程,是非常自然的。复杂的负载均衡、容灾、扩容等问题,实际上都隐藏在“查找对象”这个环节底下,开发者几乎无需关心,所以用起来会非常方便。而编写一个远程对象,也非常简单,就是写一个类,实例化一个对象,然后登记到服务器里而已。这都是面向对象编程的传统做法。由于对象本身都是带数据的,所以编写这些远程方法也会比较简单,大部分的数据都直接在本地内存读写,比如从对象成员属性里。节省了大量编写SQL或者定义和使用特别的存储设备协议的时间。
业界远程对象方案:EJB/MS-WCF/IBM-ORB
远程对象调用的框架,在业界也是常见的东西,这里大概说一下三家的: EJB, MS WCF, IBM ORB。这三家的框架大概的说明现在远程对象调用的主流用法。
一,EJB
EJB全称Enterprise Java Bean,是Java的企业分布式集群方案的核心(J2EE规范)。能部署在多个服务器上提供远程对象调用服务的JAVA对象,就称为EJB对象。底层的网络是通过JDK自带的RMI功能实现。EJB本身只是J2EE规范中的一部分,仅仅是一套接口。具体的实现由类似Weblogic这样的“EJB容器”软件提供。EJB之所以不及SSH(Spring Structs Hibernate)流行,很大原因就是因为这些容器软件都是商业软件,需要花很贵的价格购买。但这并不影响EJB作为一个优秀的远程对象方案的技术地位。EJB现在已经升级到3.0版本以上了,摒弃了以前配置复杂,功能晦涩的特点,大胆的使用更简单的生命周期管理、简单的注解式配置、好用的ORM能力,让EJB 3.0重新成为一流的技术。
一个客户端程序,想要访问一个EJB对象,一般需要使用一个叫做JNDI的API,来具体连接到EJB对象上。JNDI的全称是Java Naming and Directory Inerface,基本等于我们常说的名字、目录服务接口。Java通过一套API规范,来统一各种目录服务器的使用方法。所有的J2EE容器,都必须提供一个JNDI服务,而客户端程序则通过使用J2EE容器提供的JNDI来访问容器内的EJB对象。JNDI的使用方法,基本上就是输入一个字符串,然后API会返回给你一个对象。在J2EE的环境里,这个对象就是EJB对象的Home接口对象(对应远程EJB对象的一个映像,也叫桩对象)。代码类似:
Context ctx = new InitialContext(env);
Object ejbHome = ctx.lookup(“java:comp/env/ejb/HelloBean”);
HelloHome empHome = (HelloHome) PortableRemoteObject.narrow (ejbHome, HelloHome.class);
输入lookup()函数的字符串,是用户可以自己定义的任何内容,只要在对应的EJB容器里面登记了这个对应关系即可。从这个代码我们可以看到,如果EJB想要做容灾、负载均衡等功能,是完全可以通过ctx.lookup()这个接口来实现的。另外,远程对象的Home接口(桩代码)是需要预先部署在客户端测,在上面的例子里是HelloHome.class这个类。而EJB对象的这个Home接口类,是由EJB工具,自动通过来源的EJB对象类定义生成的。对比CORBA,Thrift等技术,EJB可以直接用.java源代码代替IDL定义,然后自动生成桩代码,这确实是简便很多。
EJB规范把远程对象定义为三种:无状态会话Bean,有状态会话Bean,消息驱动Bean。这意味着EJB容器对于EJB对象的生命周期是有管理的。其中无状态会话Bean和消息驱动Bean的声明周期是类似的,都是来一个请求(消息驱动的意思是每来一个JMS消息),就可能new一个Bean对象。当然也可能不是每次请求都新建对象,总之容器不保证会保持Bean对象的生存周期,这样容器可以根据负载压力,灵活的管理众多的Bean对象。而最特别的是“有状态会话Bean”,容器会根据客户端的会话状态(和客户端的context对象对应),来保持Bean对象,也就是说,每个客户端context对应一个有状态Bean。如果你用这个客户端context,发起多次lookup()查找,访问的那个EJB对象都将会是同一个。这对于需要保持登录状态的服务,就非常方便了。客户无需自己去维持一个远程对象的生命周期,而能得到状态保存的功能。
最后说说EJB的部署配置,以前的EJB容器部署异常复杂。除了需要写一个继承于特定基类的业务JAVA类外,还要配置很多细节。而EJB3.0之后,通过JAVA注解功能(Annotation),这些配置都可以和源代码写到一起,而业务JAVA类也无需集成特定的接口和类型,可以是任何一个普通的类(POJO),只是需要加上一些特定的注释即可。EJB容器提供工具对这些加了EJB注释的JAVA类进行处理,一方面把这个JAVA类自动部署到容器中,另一方面生成客户端的Home接口类文件,供用户发布(拷贝)到需要使用的客户方服务器上去。而一些EJB容器(如Weblogic)还提供了Eclipse(IDE)的图形界面工具,让整个过程几乎都不在需要编写额外的配置和命令行操作。
二,MS WCF
WCF全称Windows Communication Foundation,是微软发布的用于构建面向服务的应用程序框架。这套框架的底层是Windows的COM+技术,而编程接口则更多的使用C#语言/VB语言和.Net平台。这和EJB有一定的类似,差别就是WCF中的远程对象,不需要一个像JVM那样的虚拟机,而是结合在WINDOWS操作系统里。
无独有偶,WCF的远程接口定义,也是直接使用C#/VB代码,加上类似注解的“特性”(Attribute)功能注释,标注在一个定义好的接口(Interface)上来组成的。具体的业务实现类,只要“实现”定义的这个接口就可以了,和一个普通的类没有任何差别。和EJB的差别是,我们还是需要写一段XML配置,把这个远程对象的接口和查找字符串,注册到万能的IIS服务器里面。一旦注册完成,就可以通过URL:http://xx.xx.xx.xx/servicesname/service.svc 这样的字符串去访问了。同时,如果客户端想要访问这个远程对象,则需要使用svcuitl.exe这个工具,输入刚刚注册的那个URL,就可以生成对应的客户端桩代码库。客户端可以直接new这个新建立的桩类型对象,然后直接调用其方法,就和调用本地对象的方法一样。
// Create a client.
CalculatorClient client = new CalculatorClient();
// Call the Add service operation.
double value1 = 100.00D;
double value2 = 15.99D;
double result = client.Add(value1, value2);
Console.WriteLine("Add(,) = ", value1, value2, result);
当然,如果你想连接不同的服务器,还是有机会的,一位内生成的客户端代码,会使用一个配置文件。在里面可以修改远程服务器的地址(还是那个注册的URL)。
address="http://localhost/servicemodelsamples/service.svc"
binding="wsHttpBinding"
contract=" Microsoft.ServiceModel.Samples.ICalculator" />
你除了可以通过IIS来提供WCF的远程对象服务外,还可以自己写一个单独的程序,通过定义main()来完全的控制这些远程对象,从而提供服务。另外,WCF除了通过URL直接对应一个远程对象外,还可以通过编写“路由服务”,来对同一个URL的远程对象调用进行灵活的路由。虽然WCF没有提供类似EJB的远程对象生命周期管理功能,但是你完全可以通过WCF的服务API和路由服务,来自己编码实现任何形式的远程对象生命周期管理。
三,IBM RMI-IIOP
IBM公司的RMI-IIOP服务,是以JAVA技术为基础的,但是又不同于EJB的另外一套远程对象技术。这套技术更接近于以JAVA为基础实现的CORBA体系。这个技术的使用标准的JAVA RMI接口(RMIInterface)作为远程对象的接口,使用JAVA的序列化、反序列化能力作为编码能力。然后自己写一个main() 函数,建立一个org.omg.CORBA.ORB对象来构造一个远程服务器。而客户端则是通过一个字符串来定位想要访问的远程对象。这个字符串类似:corbaloc:iiop:1.2@localhost:8080/OurLittleClient 。我们可以看到这里面有IP和端口,还有一个编写服务器远程对象时注册的字符串OurlLittleClient。我们通过rmic –iiop Server这样的命令行部署远程对象,然后用start java Server启动服务器,用start java Client启动客户机。这些命名,都是包含在IBM Developer Kit for Java technology v1.3.1里面的。我们可以发现,RMI-IIOP是一个更加原始的远程对象方案,基本上就是一个CORBA的API实现的组合。使用起来有点繁琐,但是好处是不需要学习和部署复杂的容器服务,可以完全自己编码去实现一套远程对象服务。这里没有限定你使用什么方法去定位查找远程对象,也没有限定你怎么管理远程对象的生命周期,一切都由开发者自己去编写实现。
总结
在对象定位的选择上,通过字符串查找已经是标准,而复杂的自定义路由也可以隐藏在这个查找操作下面。远程对象的生命周期管理,实际上是对服务器资源的管理,除了EJB有容器支持以外,其他的方案都比较少提供这样的能力,说明这一块是比较困难的。服务器部署方面,可以让用户以API自己写main()去构建服务器,提供了极大的灵活性。
远程对象的挑战:生命周期管理、数据一致性
通过上面的分析,我们可以发现,远程对象的生命周期管理,是一个比较重大且复杂的课题。我们要保证这样的生命周期管理程序,能有一个通用的策略,来保持各种业务情况下的服务器资源稳定,是比较困难的。而且在分布式系统的情况下,为了负载均衡,还要把同样类型的远程对象,部署到不同的进程上,这就引入了一个新的问题:数据一致性。
远程对象的生命周期,除了占用服务器的内存资源外,还会占用记录其地址的路由空间,检查维护生命周期的CPU运算时间。如果我们提供自动化的对象生命周期管理,势必就需要在客户使用的时候,提供这方面的教育,以及防止客户使用错误、过载等情况下对象管理失效的防御性策略。所以即便是EJB容器,也仅仅提供了非常简单的生命周期管理策略:会话状态、无状态这两种。
对于一般的互联网应用,只有EJB这两种生命周期管理的远程对象,基本上是够用的。因为一般的互联网应用,大部分数据都是持久化数据,需要读写数据库。临时状态数据一般来说不多,主要是用户登录后的产生的一些过程数据,有一个“会话(Session)”类型的生命周期就足够了。但是,如果我们的业务是网络游戏,那么这么简单的生命周期就是完全不够的,因为游戏中有大量的临时状态,比如组队的状态,玩家所在房间的状态,关卡副本的状态等等。这些临时状态,都是需要我们通过业务逻辑代码,来控制和管理所对应的对象生命周期的。所以一个适合游戏的远程对象系统,需要提供让客户端程序来选择,“新建/初始化”和“销毁”远程对象的能力。
在对远程对象进行管理的时候,我们常常会用到一种叫“对象池”的技术,使用这种技术避免频繁的新建和销毁对象。但是如果这些对象的是带状态的,那么我们的“池”就必须带索引,并且对象也必须有一个key。同时我们的对象还需要有一个“reset”的重置方法,用来让对象回归到初始化状态。
在分布式的系统下,我们的对象池因为是分别存放在不同的机器上,所以其一致性的维护往往是比较困难的。但是,我们可以把这个问题,转换成构建一个“分布式对象池”的问题。假如每个对象池,都按KEY的某个规律,如一致性哈希,存放不同的对象。那么只要在远程调用发起的时候,也就是通过lookup()查找远程对象的时候,把请求导向到对象所在进程,那么就能很方便的从本地进程对象池中获得对象。远程对象的“定位”和“一致性”在查找对象这个环节结合起来,是一个非常好的想法。这样能让远程状态对象的使用进一步简化,用户完全无需关心远程对象在什么地方,又能快速的访问到正确的对象。
[扩容下的远程对象迁移]
当分布式的对象容器出现部分进程故障,或者需要动态扩容的时候,只要我们针对对象查找的数据做某种程度的数据搬迁,或者缓存清理,就能很容易的实现对象的重新分布。如果对象同时能够支持持久化,那么这种数据搬迁,只需要简单的让对象写入持久化。然后在新的机器上,通过缓存建立的策略,从持久化设备读取出对象即可。
总结
远程对象调用,是一种业界成熟的分布式服务器系统模型。这套模型提供了强大的分布式程序架构能力,并且能方便的置入统一的运维特性能力:容灾、扩容、负载均衡。
它比远程方法调用,增加了对数据位置的指向,能有效的提高系统的响应速度。同时面向对象的形态,也能显著降低复杂逻辑的开发成本。
远程对象的生命周期管理,实际上一种分布式缓存系统的管理。良好的远程对象系统,能提高丰富的生命周期管理功能,以适合网络游戏,这种需要处理丰富临时状态的行业需求。
如果我们把远程对象的寻址和数据一致性维护结合起来,并且提供对象的持久化支持,那么远程对象调用将是一个高度自动化,且具有自我维护能力的强大分布式计算系统。
领取专属 10元无门槛券
私享最新 技术干货