04 泛化调用:三步教你搭建通用的泛化调用框架
你好,我是何辉。我们继续探索Dubbo框架的第三道特色风味,泛化调用。
提到调用,我想你肯定不陌生,前面我们也学习过同步调用、异步调用,核心是为了解决数据端到端传输的调用问题。那今天要学习的新型调用技能——泛化调用到底是什么?又能解决哪些实际问题呢?话不多说,我们马上开始。
我们都知道,页面与后台的交互调用流程一般是,页面发起HTTP请求,首先到达Web服务器,然后由Web服务器向后端各系统发起调用:
假设这是一次查询用户信息的请求,核心逻辑完全在后端系统,你一般在Web服务器会怎么写呢?
这个简单,你自信秀出了代码:
@RestController
public class UserController {
// 响应码为成功时的值
public static final String SUCC = "000000";
// 定义访问下游查询用户服务的字段
@DubboReference
private UserQueryFacade userQueryFacade;
// 定义URL地址
@PostMapping("/queryUserInfo")
public String queryUserInfo(@RequestBody QueryUserInfoReq req){
// 将入参的req转为下游方法的入参对象,并发起远程调用
QueryUserInfoResp resp =
userQueryFacade.queryUserInfo(convertReq(req));
// 判断响应对象的响应码,不是成功的话,则组装失败响应
if(!SUCC.equals(resp.getRespCode())){
return RespUtils.fail(resp);
}
// 如果响应码为成功的话,则组装成功响应
return RespUtils.ok(resp);
}
}
在 UserController 控制器中,定义了一个查询用户信息的URL地址,接着在 queryUserInfo 方法中,将 HTTP的请求参数对象转换为下游接口的入参对象,然后调用下游接口发起远程调用,最后针对响应码做判断,并包装下游对象返回给前端。
代码逻辑非常简单,这样的查询用户请求在日常开发中我们也写过不少了,那对于这样的功能,你能总结出开发流程步骤么?
给个小提示,可以分析一下 queryUserInfo 的实现体逻辑,如果不确定也可以试着再写几个类似的功能,就能很顺利地总结出来了。
开发流程步骤就是:
- 定义 URL 地址给前端
- 新建 Controller 控制器
- 新增一个方法
- 方法上添加处理请求的注解
- 定义访问下游服务的变量
- 发起远程调用
- 判断响应成功或者失败
- 将结果包装后传给前端
在总结开发步骤的同时,不知道你有没有发现一条惊人的规律:queryUserInfo 在 UserController 中没有实质性的业务逻辑。Web服务器只是编写代码把数据做了下包装,然后给到下游系统,等收到下游系统返回的内容后,啥也不做直接返回给前端,也就是说, Web服务器其实是在做一些透传性质的事情。
而且这里我们只是写了一个接口,如果现在有十几个的运营页面,大约五十个请求接口,每个请求的核心逻辑都在后端系统,你预估一下,在Web服务器中写Java代码大概要写多久?
这样的需求量,我们掐指一算,每个接口假设编码加自测需要2小时,50个接口按10小时一天来算,10天才能写完,前提是下游系统都把接口提供好,如果下游没有按时提供接口,耗时想想都头皮发麻,这开发效率属实有点低下了。
那对于透传性质的Web服务器开发,有没有更好的方式来提效呢?
反射调用
你可能会说这还不简单,可以把一些相似的代码用一个小方法包装起来,减少代码修改量。那该减少哪些代码呢?
乍一看你可能没有思路,那我们换个问题:如果要写一个类似功能,你会怎么写呢,要改动哪些位置?
现在你一定很有把握了吧,是时候让CV大法发挥威力了,日常面对需求,在整体流程中每一步都蜻蜓点水地改一点点,三下五除二就把代码改完了。那我们结合刚才总结的开发流程8大步,看看改动点都有哪些:
- 流程1(定义 URL 地址给前端),将 /queryUserInfo重新定义为一个新路径。
- 流程2(新建 Controller 控制器),将 UserQueryFacade userQueryFacade 字段定义为新的接口字段。
- 流程3(新增一个方法),将 queryUserInfo 方法名改成了新的方法名。
- 流程6(发起远程调用),修改了 convertReq 方法中的字段映射逻辑,并修改了 userQueryFacade.queryUserInfo 调用形式为新的接口调用。
来挨个分析哪些改动是可以提炼的。
- 改动点一:每次新加功能都新增了一个Controller控制器,可以省去吗?
仔细想想,其实,也可以不新增。
既然可以不新增的话,为什么我们又每次都新增呢?很多人的第一想法是别人都是这么新增的,那我们也就依葫芦画瓢新增了。
那为什么大家都会新增呢,是为了和其他类不一样么?没错,相信你也意识到了,一般之所以新增控制器,主要是在做代码归类、功能归类而已,易于理解和维护。
- 改动点二:userQueryFacade 字段的定义,可以省去么?
字段的定义,如果省去,就得去Spring容器中拿,这倒是小事,但省去后 @DubboReference 注解就不好做了,这个暂时不能省。
- 改动点三:/queryUserInfo 这个路径可以省么?
这个问题你肯定很自信,没法省,省了的话用户都不知道请求什么路径了,得保留。
第四步有2个改动点,我们先看第一个。
- 改动点四:convertReq 这个方法的逻辑可以省么?
convertReq 方法就是个转换入参字段为下游入参字段的映射关系,所以可以省,但是前提是入参字段和下游字段得一样,这样就能简单粗暴地先将 req 序列化为 String,再将 String 反序列化为下游入参对象。
好再看第二个。
- 改动点五:userQueryFacade.queryUserInfo 这个调用可以省么?
这得结合改动点二,因为 userQueryFacade 字段定义不能省去,想要逻辑通用,我们顶多可以把 userQueryFacade 对象和 queryUserInfo 传到公用方法里去。 那有了对象和方法名,怎么调用到对象的方法上呢?
这不是正常的调用方式,联想下已学的 Java 基础知识,看看哪个知识点可以在知道对象和方法名的时候发起调用的。没错就是“反射”,反射的时候记得设置 setAccessible(true),以避免无访问权限。
好,我们小结一下哪些代码是可以省的:
大致有三块,节省控制器的创建、convertReq转换逻辑通用化处理、userQueryFacade.queryUserInfo调用方式。
修改后的代码就是这个样子:
@RestController
public class UserController {
// 响应码为成功时的值
public static final String SUCC = "000000";
// 定义访问下游查询用户服务的字段
@DubboReference
private UserQueryFacade userQueryFacade;
// 定义URL地址
@PostMapping("/queryUserInfo")
public String queryUserInfo(@RequestBody QueryUserInfoReq req){
// 调用公共方法
return commonInvoke(userQueryFacade, "queryUserInfo", req);
}
/**
* <h2>模拟公共的远程调用方法.</h2>
*
* @param reqObj:下游的接口的实例对象,即通过 @DubboReference 得到的对象。
* @param mtdName:下游接口的方法名。
* @param reqParams:需要请求到下游的数据。
* @return 直接结果数据。
* @throws InvocationTargetException
* @throws IllegalAccessException
*/
public static String commonInvoke(Object reqObj, String mtdName, Object reqParams) throws InvocationTargetException, IllegalAccessException {
// 通过反射找到 reqObj(例:userQueryFacade) 中的 mtdName(例:queryUserInfo) 方法
Method reqMethod = ReflectionUtils.findMethod(reqObj.getClass(), mtdName);
// 并设置查找出来的方法可被访问
ReflectionUtils.makeAccessible(reqMethod);
// 通过序列化工具将 reqParams 序列化为字符串格式
String reqParamsStr = JSON.toJSONString(reqParams);
// 然后再将 reqParamsStr 反序列化为下游对象格式,并反射调用 invoke 方法
Object resp = reqMethod.invoke(reqObj, JSON.parseObject(reqParamsStr, reqMethod.getParameterTypes()[0]));
// 判断响应对象的响应码,不是成功的话,则组装失败响应
if(resp == null || !SUCC.equals(OgnlUtils.getValue(resp, "respCode"))){
return RespUtils.fail(resp);
}
// 如果响应码为成功的话,则组装成功响应
return RespUtils.ok(resp);
}
}
代码大大改善了 queryUserInfo 的实现逻辑,并且提炼出了一个 commonInvoke 通用的方法,其中主要做了4件事情:
- 通过反射功能找到下游接口的 method 对象;
- 通过先序列化,再反序列化,将HTTP入参转为下游接口入参对象;
- 通过 method.invoke 反射发起真正的远程调用,并拿到响应对象;
- 通过 Ognl 表达式语言从响应对象取出 respCode 响应码做判断,并做最终返回。
这样精简后,我们发现 queryUserInfo 的实现逻辑确实简单多了,节省代码的同时,还提炼了公共方法,的确可以节省不少开发工作量。
可是,queryUserInfo 的内部逻辑是精简了,仍然得定义好多 queryUserInfo 这样的方法、请求URL地址、下游接口字段,这些是否也能省略呢?
单纯靠现有的反射调用方式无法进一步精简了,我们需要考虑定制一种更新型的调用方式。
泛化调用
如何创建一种新型调用呢?这个方向说起来简单,做起来还是比较困难的,但作为未来技术精英的你,思考与不思考是两码事,思考了也许会有意想不到的新发现,不思考就是原地踏步了。虽然创建一种新型调用暂时没有什么头绪,但是我们还是可以先解决眼前的一些问题。
前面也提到需要定义好多请求URL地址, 那URL地址能不能精简呢? 这可能是新突破口。
想精简URL地址,首先要理解为什么要在Controller里面设置URL这个东西?在Controller里面设置URL就是为了能让前端调用到。
其次,URL一般写在像@PostMapping、@GetMapping 这样的注解中,那URL是怎么被访问通的呢?
考虑到@PostMapping、@GetMapping 是 Spring 体系中的,我们可以在 Spring 框架中尝试找答案,看官方文档中URL的定义规则,果不其然,在 Spring Web MVC -> Annotated Controllers -> URI patterns 路径下我们找到了解释。
核心就是 RequestMappingHandlerMapping 的 initHandlerMethods 方法里面的URL注册器,请求链接被URL注册器匹配成功了,就可以被访问通。
所以,我们可以使用类似这种 /projects/{project}/versions
占位符形式的URL,利用 RequestMappingHandlerMapping 中的URL注册器去匹配。如果可以 把一些变化的因子放到URL占位符中,精简URL的概率就非常大了。
好思路有了,我们再回看 commonInvoke 这个核心方法看看如何修改:
/**
* <h2>模拟公共的远程调用方法.</h2>
*
* @param reqObj:下游的接口的实例对象,即通过 @DubboReference 得到的对象。
* @param mtdName:下游接口的方法名。
* @param reqParams:需要请求到下游的数据。
* @return 直接结果数据。
* @throws InvocationTargetException
* @throws IllegalAccessException
*/
public static String commonInvoke(Object reqObj,
String mtdName,
Object reqParams) throws InvocationTargetException, IllegalAccessException {
}
这个方法有3个参数是变化的:
- reqObj,下游接口对象,如果能想办法处理掉 @DubboReference 修饰接口的功能,这个估计也可以省略掉,但首先得拿到对象,而要拿到对象就得先知道是哪个类。所以对象的全类名可以考虑放到URL路径上作为变量。
- mtdName,下游接口的方法名,可以考虑放到URL路径上作为变量。
- reqParams,请求业务参数,按照对象接收的话得定义很多对象和字段,如果按照String接收的话貌似可行。
我们先尝试修改一下:
@RestController
public class CommonController {
// 响应码为成功时的值
public static final String SUCC = "000000";
// 定义URL地址
@PostMapping("/gateway/{className}/{mtdName}/request")
public String commonRequest(@PathVariable String className,
@PathVariable String mtdName,
@RequestBody String reqBody){
// 将入参的req转为下游方法的入参对象,并发起远程调用
return commonInvoke(className, mtdName, reqBody);
}
/**
* <h2>模拟公共的远程调用方法.</h2>
*
* @param className:下游的接口归属方法的全类名。
* @param mtdName:下游接口的方法名。
* @param reqParamsStr:需要请求到下游的数据。
* @return 直接返回下游的整个对象。
* @throws InvocationTargetException
* @throws IllegalAccessException
*/
public static String commonInvoke(String className,
String mtdName,
String reqParamsStr) throws InvocationTargetException, IllegalAccessException, ClassNotFoundException {
// 试图从类加载器中通过类名获取类信息对象
Class<?> clz = CommonController.class.getClassLoader().loadClass(className);
// 然后试图通过类信息对象想办法获取到该类对应的实例对象
Object reqObj = tryFindBean(clz.getClass());
// 通过反射找到 reqObj(例:userQueryFacade) 中的 mtdName(例:queryUserInfo) 方法
Method reqMethod = ReflectionUtils.findMethod(clz, mtdName);
// 并设置查找出来的方法可被访问
ReflectionUtils.makeAccessible(reqMethod);
// 将 reqParamsStr 反序列化为下游对象格式,并反射调用 invoke 方法
Object resp = reqMethod.invoke(reqObj, JSON.parseObject(reqParamsStr, reqMethod.getParameterTypes()[0]));
// 判断响应对象的响应码,不是成功的话,则组装失败响应
if(!SUCC.equals(OgnlUtils.getValue(resp, "respCode"))){
return RespUtils.fail(resp);
}
// 如果响应码为成功的话,则组装成功响应
return RespUtils.ok(resp);
}
}
因为可以通过占位符来精简URL,我们对代码做了4个调整:
- 重新定义了一个公用控制器 CommonController;
- 定义了统一的URL路径
/gateway/{className}/{mtdName}/request
,将 className、mtdName 做成请求路径的占位符; - 修改了请求业务参数的格式定义,由对象转为String;
- commonInvoke 核心实现逻辑中,利用类加载器找到 className 对应的类信息,然后想办法找到 className 对应的实例对象。
但这段代码有一个重要的核心逻辑还没解决, tryFindBean,我们该通过什么样的办法拿到下游接口的实例对象呢?或者说,该怎么仿照 @DubboReference 注解,拿到下游接口的实例对象呢?
虽然不知道 @DubboReference 注解是怎么做到的,但是我们起码能明白一点,只要通过 @DubboReference 修饰的字段就能拿到实例对象,那接下来就是需要一点耐心的环节了,顺着 @DubboReference 注解的核心实现逻辑探索一下源码:
最终,我们会发现是通过 ReferenceConfig#get 方法创建了代理对象。你也可以参考我录制的这段视频,探寻答案:
这句代码不就是我们平常写Java代码拿到下游接口代理对象的方式么。原来核心的解决方案就在眼前,一个不起眼的 @DubboReference 注解的核心实现逻辑,就是我们最普通的拿到接口代理对象的逻辑。
那接下来,你知道该怎么继续改造了么?
编码实现
经过一番源码探索后,最难解决的 tryFindBean 逻辑也有了头绪。我们找到了 ReferenceConfig 这个核心类,接下来要做的就是拿到 referenceConfig#get 返回的泛化对象GenericService,然后调用 GenericService#$invoke 方法进行远程调用。
看下GenericService#$invoke 方法的定义:
public interface GenericService {
/**
* Generic invocation
*
* @param method Method name, e.g. findPerson. If there are overridden methods, parameter info is
* required, e.g. findPerson(java.lang.String)
* @param parameterTypes Parameter types
* @param args Arguments
* @return invocation return value
* @throws GenericException potential exception thrown from the invocation
*/
Object $invoke(String method, String[] parameterTypes, Object[] args) throws GenericException;
}
可以从源码中看到,GenericService#$invoke 方法除了需要下游接口方法名、下游请求参数外,还需要一个下游的方法入参类名。
既然 $invoke 方法这么定义了,那我们可以把方法入参的类名也定义为变量。虽然源码这里是接收一个数组,但只传一个一般也能解决绝大部分问题,所以这里,我们就先按照只有一个方法入参类名来定义URL接口。
按思路来调整代码:
@RestController
public class CommonController {
// 响应码为成功时的值
public static final String SUCC = "000000";
// 定义URL地址
@PostMapping("/gateway/{className}/{mtdName}/{parameterTypeName}/request")
public String commonRequest(@PathVariable String className,
@PathVariable String mtdName,
@PathVariable String parameterTypeName,
@RequestBody String reqBody){
// 将入参的req转为下游方法的入参对象,并发起远程调用
return commonInvoke(className, parameterTypeName, mtdName, reqBody);
}
/**
* <h2>模拟公共的远程调用方法.</h2>
*
* @param className:下游的接口归属方法的全类名。
* @param mtdName:下游接口的方法名。
* @param parameterTypeName:下游接口的方法入参的全类名。
* @param reqParamsStr:需要请求到下游的数据。
* @return 直接返回下游的整个对象。
* @throws InvocationTargetException
* @throws IllegalAccessException
*/
public static String commonInvoke(String className,
String mtdName,
String parameterTypeName,
String reqParamsStr) {
// 然后试图通过类信息对象想办法获取到该类对应的实例对象
ReferenceConfig<GenericService> referenceConfig = createReferenceConfig(className);
// 远程调用
GenericService genericService = referenceConfig.get();
Object resp = genericService.$invoke(
mtdName,
new String[]{parameterTypeName},
new Object[]{JSON.parseObject(reqParamsStr, Map.class)});
// 判断响应对象的响应码,不是成功的话,则组装失败响应
if(!SUCC.equals(OgnlUtils.getValue(resp, "respCode"))){
return RespUtils.fail(resp);
}
// 如果响应码为成功的话,则组装成功响应
return RespUtils.ok(resp);
}
private static ReferenceConfig<GenericService> createReferenceConfig(String className) {
DubboBootstrap dubboBootstrap = DubboBootstrap.getInstance();
// 设置应用服务名称
ApplicationConfig applicationConfig = new ApplicationConfig();
applicationConfig.setName(dubboBootstrap.getApplicationModel().getApplicationName());
// 设置注册中心的地址
String address = dubboBootstrap.getConfigManager().getRegistries().iterator().next().getAddress();
RegistryConfig registryConfig = new RegistryConfig(address);
ReferenceConfig<GenericService> referenceConfig = new ReferenceConfig<>();
referenceConfig.setApplication(applicationConfig);
referenceConfig.setRegistry(registryConfig);
referenceConfig.setInterface(className);
// 设置泛化调用形式
referenceConfig.setGeneric("true");
// 设置默认超时时间5秒
referenceConfig.setTimeout(5 * 1000);
return referenceConfig;
}
}
代码中主要解决了怎么找到接口代理对象的核心逻辑问题,关键步骤是:
- URL地址增加了一个方法参数类名的维度,意味着通过类名、方法名、方法参数类名可以访问后台的提供者;
- 通过接口类名来创建 ReferenceConfig 对象,并设置 generic = true 的核心属性;
- 通过 referenceConfig.get 方法得到 genericService 泛化对象;
- 将方法名、方法参数类名、业务请求参数传入泛化对象的 $invoke 方法中进行远程Dubbo调用,并返回响应对象;
- 通过 Ognl 表达式语言从响应对象取出 respCode 响应码判断并做最终返回。
到这里我们今天的学习任务就大功告成了,把枯燥无味的代码用泛化调用形式改善了一番,发起的请求,先经过“泛化调用”,然后调往各个提供方系统,这样发起的请求根本不需要感知提供方的存在,只需要按照既定的“泛化调用”形式发起调用就可以了。
通俗地讲,泛化可以理解为采用一种统一的方式来发起对任何服务方法的调用,至少我们知道是一种接口调用的方式,只是这种方式有一个比较独特的名字而已。
泛化调用的应用
学习了泛化调用,想必你已经可以很娴熟地封装自己的通用网关了,在我们日常开发中,哪些应用场景可以考虑泛化调用呢?
第一,透传式调用,发起方只是想调用提供者拿到结果,没有过多的业务逻辑诉求,即使有,也是拿到结果后再继续做分发处理。
第二,代理服务,所有的请求都会经过代理服务器,而代理服务器不会感知任何业务逻辑,只是一个通道,接收数据->发起调用->返回结果,调用流程非常简单纯粹。
第三,前端网关,有些内网环境的运营页面,对URL的格式没有那么严格的讲究,页面的功能都是和后端服务一对一的操作,非常简单直接。
总结
今天,我们从Web服务器一段常见的代码调用开始,用反射调用和泛化调用两种调用方案做了不同尝试,核心仍然是希望简化开发人员代码编写,提升功能通用性。
反射调用方案,只需要通过接口对象、方法名、入参对象就可以完成远程调用,但仍然无法规避多个URL、多个控制器方法、多个下游接口字段等的定义。
接着在Spring官方文档的指导下,我们根据占位符的概念,重新设计URL格式,把调用下游的接口类名、方法名利用占位符的方式定义到URL中。但简化URL后,又遇到了如何获取下游接口对象的难题,我们从 @DubboReference 的实现逻辑中挖出了 ReferenceConfig 核心关键类。
最后通过泛化调用,提炼出通过接口类名、接口方法名、接口方法参数类名、业务请求参数四个维度完成了最终方案落地。
这里也总结一下泛化调用的三部曲:
- 接口类名、接口方法名、接口方法参数类名、业务请求参数,四个维度的数据不能少。
- 根据接口类名创建 ReferenceConfig 对象,设置 generic = true 属性,调用 referenceConfig.get 拿到 genericService 泛化对象。
- 传入接口方法名、接口方法参数类名、业务请求参数,调用 genericService.$invoke 方法拿到响应对象,并通过 Ognl 表达式语言判断响应成功或失败,然后完成数据最终返回。
泛化调用的应用场景主要有3类,透传式调用、代理服务、前端网关。
思考题
你已经学会了使用泛化调用,并且也掌握了泛化调用的三部曲,基本上可以利用 CommonController 的代码调用流程来设计一套属于自己的通用型网关了,你觉得 CommonController 这段代码还有哪些地方可以改善吗?
欢迎留言参与讨论,如果有收获也欢迎分享给身边的朋友,说不定就帮他解决了一个问题,我们下一讲见。
03思考题参考
上一期的问题是研读RpcContext类,说说 SERVER_LOCAL、CLIENT_ATTACHMENT、SERVER_ATTACHMENT、SERVICE_CONTEXT 这几个属性的生命周期。
SERVER_LOCAL 作用于 Provider 侧,在 org.apache.dubbo.rpc.RpcContext.RestoreContext#restore 中被设置进去,即在线程切换将父线程的信息拷贝至子线程时被调用,然而却又在 Provider 转为 Consumer 角色时被清除数据。
CLIENT_ATTACHMENT 在源码中是这么描述的:
CLIENT_ATTACHMENT 用于将附属信息作为 Consumer 传递到下一跳 Provider,在 Provider 和 Consumer 的 Filter 中的 org.apache.dubbo.rpc.BaseFilter.Listener#onResponse、org.apache.dubbo.rpc.BaseFilter.Listener#onError 方法都会被清除数据。
SERVER_ATTACHMENT 在源码中是这么描述的:
ServerAttachment is using to fetch attachments from previous hop as a provider. ( A --> B , in B side)
SERVER_ATTACHMENT 作为 Provider 侧用于接收上一跳 Consumer 的发来附属信息,在 Provider 和 Consumer 的 Filter 中的 org.apache.dubbo.rpc.BaseFilter.Listener#onResponse、org.apache.dubbo.rpc.BaseFilter.Listener#onError 方法都会被清除数据。
SERVICE_CONTEXT 在源码中是这么描述的:
ServerContext is using to return some attachments back to client as a provider. ( A <-- B , in B side)
SERVICE_CONTEXT 用于将附属信息作为 Provider 返回给 Consumer,在 Provider 侧的 org.apache.dubbo.rpc.filter.ContextFilter#onResponse、org.apache.dubbo.rpc.filter.ContextFilter#onError 方法都会被清除数据。