1、服务治理
通过前面两篇文章(《架构设计:系统间通信(12)——RPC实例Apache Thrift 中篇》、《架构设计:系统间通信(11)——RPC实例Apache Thrift 上篇》)的介绍,相信读者已经可以将Apache Thrift应用到实际工作中,并且理解了为什么Apache Thrift的性能要比大多数RPC框架优秀。但如果您使用过Apache thrift,那么相信您会发现它的一些不足(或者说是所有单纯的RPC框架的不足):
- 由于Apache Thrift使用IDL定义RCP 调用接口,实现跨语言性。那么一旦当业务发生变化后,是否要重新编写IDL,重新生成接口代码呢?
- 如果以上的事实成立,那如果在生成环境使用了多种语言,且服务节点又很多的情况下。岂不是重新部署的工作量会很大?
- 另外,生产环境的服务是不能停机的?那么就会出现一部分接口是新部署的,另外一部分接口是还未更新的。服务者怎么保证接口的稳定呢?
- 再说,我的生产环境下一共有20个相对独立运行的系统:计费系统、客户系统、订单系统、库存系统、物流系统、税务联动系统,等等。负责他们的开发团队都是不一样的。如何在某个系统的接口发生变动后,通知到其它系统“我的接口变动了”?即便是不能通知到所有系统“我的接口变动了”,又如何做到之前的接口也一样可以使用呢?
显然以上这些问题,单纯使用Apache Thrift(或者单纯的某一款RPC框架)是无法解决的;使用人工的方式就更不要想解决了。如果您的相关系统只有2-3个,又或者每个系统的服务节点数量也不多(例如5、6个),那么以上这些问题还不太明显。但是随着您的系统越来越大,系统间协作越来越复杂,那么这些问题就会凸现出来,甚至成为影响您架构扩容的显著问题。
解决这个问题的方式, 阿里的做法是在众多系统的RPC通信的上层再架一层专门进行RPC通信的协调管理,称之为服务治理框架 (DUBBO框架,目前这个框架已经开源,在后面的文章中,我会花比较大的篇幅进行介绍。和DUBBO框架类似的还有Taobao的HSF)。事实上现在的软件架构中,都是使用相似的“服务治理”思想,来解决这个问题的。如下图所示:
- 当服务提供者能够向外部系统提供调用服务时(无论这个调用服务是基于RPC的还是基于Http的,一般来说前者居多),它会首先向“服务管理组件”注册这个服务,包括服务名、访问权限、优先级、版本、参数、真实访路径、有效时间等等基本信息。
- 当某一个服务使用者需要调用服务时,首先会向“服务管理组件”询问服务的基本信息。当然“服务管理组件”还会验证服务使用者是否有权限进行调用、是否符合调用的前置条件等等过滤。最终“服务管理组件”将真实的服务提供者所在位置返回给服务使用者。
- 服务使用者拿到真实服务提供者的基本信息、调用权限后,再向真实的服务提供者发出调用请求,进行正式的业务调用过程。
在服务治理的思想中,包含几个重要元素:
- 服务管理组件:这个组件是“服务治理”的核心组件,您的服务治理框架有多强大,主要取决于您的服务管理组件功能有多强大。它至少具有的功能包括:服务注册管理、访问路由;另外,它还可以具有:服务版本管理、服务优先级管理、访问权限管理、请求数量限制、连通性管理、注册服务集群、节点容错、事件订阅-发布、状态监控,等等功能。
- 服务提供者(服务生产者):即服务的具体实现,然后按照服务治理框架特定的规范发布到服务管理组件中。这意味着什么呢?这意味着,服务提供者不一定按照RPC调用的方式发布服务,而是按照整个服务治理框架所规定的方式进行发布(如果服务治理框架要求服务提供者以RPC调用的形式进行发布,那么服务提供者就必须以RPC调用的形式进行发布;如果服务治理框架要求服务提供者以Http接口的形式进行发布,那么服务提供者就必须以Http接口的形式进行发布,但后者这种情况一般不会出现)。
- 服务使用者(服务消费者):即调用这个服务的用户,调用者首先到服务管理组件中查询具体的服务所在的位置;服务管理组件收到查询请求后,将向它返回具体的服务所在位置(视服务管理组件功能的不同,还有可能进行这些计算:判断服务调用者是否有权限进行调用、是否需要生成认证标记、是否需要重新检查服务提供者的状态、让调用者使用哪一个服务版本等等)。服务调用者在收到具体的服务位置后,向服务提供者发起正式请求,并且返回相应的结果。第二次调用时,服务请求者就可以像服务提供者直接发起调用请求了(当然,您可以有一个服务提供期限的设置,使用租约协议就可以很好的实现)。
2、设计一个服务治理框架
为了更深入理解服务治理框架的作用、工作原理,下面我们就以Apache Thrift为服务治理框架基础技术,来实现一个简单的服务治理框架。为了保证快速实现,我们使用zookeeper作为服务管理组件的基础技术(如果您不清楚zookeeper的相关技术点,可以参考我另外的几篇文章《hadoop系列:zookeeper(1)——zookeeper单点和集群安装》、《hadoop系列:zookeeper(2)——zookeeper核心原理(选举)》、《hadoop系列:zookeeper(3)——zookeeper核心原理(事件)》)。下图为简单的工作原理:
2-1、涉及技术
2-1-1、使用Zookeeper
Zookeeper是一个分布式的,开放源码的分布式应用程序协调服务,是Hadoop和Hbase的重要组件。这里我们使用Zookeeper共享“已注册的服务”。为了保证所有服务提供者都能够向Zookeeper注册提供的服务,我们需要在Zookeeper上确定一个 服务提供者和服务使用者 协商一致的“服务描述格式”。
要设计这个“服务描述格式”,首先就要清楚Zookeeper是如何记录信息的。由于我在其他文章中,已经详细讲解过Zookeeper的信息记录方式了,所以这里就只进行一些关键要素的讲解:
- Zookeeper采用树型结构目录结构记录信息。树的深度没有限制(但实际中,不可能建立很深的树结构),每一个节点成为znode。
- 每一个znode都有一个名称,为了避免出现字符集编码问题,请不要使用中文作为znode的名称。另外,同一个znode下的子级znode名称,不允许重复。
- 一个znode允许存储最多1MB大小的数据信息。
- znode根据创建性质的不一样,可分为四种行为类型不一样的znode。它们是:PERSISTENT、PERSISTENT_SEQUENTIAL、EPHEMERAL、EPHEMERAL_SEQUENTIAL。
- PERSISTENT-持久化节点:创建这个节点的客户端在与zookeeper服务的连接断开后,这个节点也不会被删除(除非您使用API强制删除)。
- PERSISTENT_SEQUENTIAL-持久化顺序编号节点:当客户端请求创建这个节点A后,zookeeper会根据parent-znode的zxid状态,为这个A节点编写一个全目录唯一的编号(这个编号只会一直增长)。当客户端与zookeeper服务的连接断开后,这个节点也不会被删除。
- EPHEMERAL-临时目录节点:创建这个节点的客户端在与zookeeper服务的连接断开后,这个节点(还有涉及到的子节点)就会被删除。
- EPHEMERAL_SEQUENTIAL-临时顺序编号目录节点:当客户端请求创建这个节点A后,zookeeper会根据parent-znode的zxid状态,为这个A节点编写一个全目录唯一的编号(这个编号只会一直增长)。当创建这个节点的客户端与zookeeper服务的连接断开后,这个节点被删除
那么按照Zookeeper的这些工作特点,我们对“服务描述格式”的结构进行了如下图所示的设计:
- Zookeeper的根目录名字叫做Service,这是一个持久化的znode节点,并且不需要存储任何数据。
- 当某一个服务提供者启动后,它将连接到Zookeeper集群,并且在Service目录下,创建一个以提供的服务名为znode名称的临时节点(例如上图所示的znode,分别叫做ServiceName1、ServiceName2、ServiceName3)。
- 每一个Service的子级znode都使用JSON格式存储两个信息,分别是这个服务的真实访问路径和访问端口。
- 这样一来,当某一个服务提供者由于某些原因不能再提供服务,并且断掉和zookeeper的连接后,它所注册的服务就会消失。通过zookeeper的通知机制(或者等待客户端的下一次询问),客户端就会知道已经没有某一个服务了。
- 对于服务调用者(服务使用者)而言,实际上并不是每一次调用服务前,都需要请求zookeeper询问访问地址。而是只需要询问一次,如果找到相关的服务,则记录到本地;待到下一次请求时,直接寻找本地的历史记录即可。
2-1-2、使用Apache Thrift
Apache Thrift的基本使用这里就不再赘述了,如果您对Apache Thrift的基本使用还不清楚,请查看前文。对于Apache Thrift的使用,在我们这个自行设计的服务治理框架中,要解决的重要问题,就是保证做到新增一个服务时,不需要重新改变IDL定义,不需要重新生成代码。
这个问题主要的解决思路就是将Apache Thrift的接口定义进行泛化,即这个接口不调用具体的业务,而只给出调用者需要调用的接口名称(包括参数),然后在服务器端,以反射的进行具体服务的调用。IDL文件进行如下的定义:
# 这个结构体定义了服务调用者的请求信息
struct Request {
# 传递的参数信息,使用格式进行表示
1:required binary paramJSON;
# 服务调用者请求的服务名,使用serviceName属性进行传递
2:required string serviceName
}
# 这个结构体,定义了服务提供者的返回信息
struct Reponse {
# RESCODE 是处理状态代码,是一个枚举类型。例如RESCODE._200表示处理成功
1:required RESCODE responeCode;
# 返回的处理结果,同样使用JSON格式进行描述
2:required binary responseJSON;
}
# 异常描述定义,当服务提供者处理过程出现异常时,向服务调用者返回
exception ServiceException {
# EXCCODE 是异常代码,也是一个枚举类型。
# 例如EXCCODE.PARAMNOTFOUND表示需要的请求参数没有找到
1:required EXCCODE exceptionCode;
# 异常的描述信息,使用字符串进行描述
2:required string exceptionMess;
}
# 这个枚举结构,描述各种服务提供者的响应代码
enum RESCODE {
_200=200;
_500=500;
_400=400;
}
# 这个枚举结构,描述各种服务提供者的异常种类
enum EXCCODE {
PARAMNOTFOUND = 2001;
SERVICENOTFOUND = 2002;
}
# 这是经过泛化后的Apache Thrift接口
service DIYFrameworkService {
Reponse send(1:required Request request) throws (1:required ServiceException e);
}
2-2、服务提供者设计思路
在给出全部示例代码前,首先就要把我们自定制的这个“服务治理”框架的设计思路讲清楚。这样各位读者在看示例代码的时候才不至于看昏过去。上文已经讲过,整个“服务治理”框架主要由四部分构成:基于zookeeper的服务管理器、服务提供者、服务调用者、为跨语言准备的IDL描述。
基于zookeeper的服务管理器,最重要的就是zookeeper中的目录结构如何设计的问题,这个问题在前文中已经讲得比较清楚,无须赘述了;为跨语言准备的IDL描述文件,以及为什么这样设计IDL描述也已经在上文中讲清楚了;那么对于服务调用者来说,最主要的就是两步调用过程:先查询zookeeper服务管理器,找到要调用的服务地址,然后请求具体服务,基本上是比较简单的,无需花很长的篇幅说明设计思路;
那么要说清楚整个“服务治理”框架的设计思路,最主要的还是说清楚服务提供者的设计思路。因为基本上所有业务过程、事件监听调用,都发生在服务提供者这一端。
2-2-1、服务提供者设计
下图表达了服务提供者的软件结构设计思路:
从上图可以看到,整个服务端的设计分为三层:
- 最外层由Zookeeper客户端和Apache Thrift服务构成。Zookeeper客户端用于向Zookeeper服务集群注册“提供的服务”;Apache Thrift用于接受服务调用者的请求,并按照格式响应处理结果。
- 由于我们定义的Apache Thrift接口(DIYFrameworkService)已经被泛化,所以具体的业务处理不能由Apache Thrift的实现(DIYFrameworkServiceImpl)来处理。由于这个原因,那么在服务端的设计中,就必须有一个服务代理层,这个服务代理层最重要的功能,就是根据Thrift收到的请求参数,决定调用哪个真实服务(在下文专门介绍具体代码的章节中,还将介绍如何集成spring,对代理层进行优化)。
- 根据软件功能需求的要求,具体的服务实现可以有多个。在设计中我们规定,所有的具体业务实现者,必须实现BusinessService接口中的handle方法。并且返回的类型都必须继承AbstractPojo。
2-2-2、功能边界确认
这里我们提供的示例设计,是为了让各位读者了解“服务治理”的基本设计原理。我们目前介绍的示例如果要应用到实际工作中,那么还需要按照读者自己的业务特点进行调整、修改甚至是重新设计。对于这个示例提供的功能来说,我们提供一些简单的,具有代表意义的就可以了:
- zookeeper服务:服务提供者的zookeeper客户端只负责连接到zookeeper服务集群,并且向zookeeper服务集群注册“服务提供者所提供的服务”。注册zookeeper时所依据的目录结构见上文中zookeeper目录结构设计的介绍。为了处理简单,zookeeper服务并不考虑性能问题,无需监听zookeeper集群上任何目录结构的变化事件,也无需将远程zookeeper集群上的目录结构缓存到本地。设计的目录结构也无需考虑一个服务由多个服务节点同时提供服务的情况。也无需考虑访问权限、访问优先级的问题。
- Apache Thrift服务:服务提供者的Apache Thrift只负责提供远程RPC调用的监听服务。而且IDL的设计也很简单(参见上文中对IDL定义格式的介绍),只要的开发语言采用JAVA,无需生成多语言的代码。采用阻塞同步的网络通讯模式,无需考虑Apache Thrift的性能问题。
- 服务代理:在正式的生产环境中,实际上服务代理层需要负责的工作是最多的。例如它要对服务请求者的令牌环进行判断,以便确定服务是否过期;要对请求者的权限进行验证;要管理具体的服务实现的注册,以便向zookeeper客户端告知注册情况;要决定具体执行哪一个服务实现,等等工作。但是为了让示例简洁,服务代理层只提供一个简单的注册管理和具体服务实现的调用。
- 服务实现在整个实例代码中,我们只提供一个服务:实现BusinessService服务层接口(business.impl.QueryUserDetailServiceImpl),查询用户详细信息的服务。并且向服务代理层注册这个服务为:”queryUserDetailService” -> “business.impl.QueryUserDetailServiceImpl”
2-2-3、建模设计
- 业务层模型设计
- 服务层设计
以上两种类简图和附带的说明,已经把示例工程中重要的设计详情进行了描述。当然工程中还有其他类,但是它们主要还是起辅助作用。例如工具类:JSONUtils、DateUtils;自定义异常:BizException;响应代码:ResponseCode;应用程序启动类:MainProcessor;这些我们将在下文具体代码中进行讲解。
(接下文)