Dubbo服务如何优雅的校验参数

服务端在向外提供接口服务时,不管是对前端提供HTTP接口,还是面向内部其他服务端提供的RPC接口,常常会面对这样一个问题,就是如何优雅的解决各种接口参数校验问题?

早期大家在做面向前端提供的HTTP接口时,对参数的校验可能都会经历这几个阶段:每个接口每个参数都写定制校验代码、提炼公共校验逻辑、自定义切面进行校验、通用标准的校验逻辑。

这边提到的通用标准的校验逻辑指的就是基于JSR303的Java Bean Validation,其中官方指定的具体实现就是 Hibernate Validator,在Web项目中结合Spring可以做到很优雅的去进行参数校验。

本文主要也是想给大家介绍下如何在使用Dubbo时做好优雅的参数校验。


二、解决方案

Dubbo框架本身是支持参数校验的,同时也是基于JSR303去实现的,我们来看下具体是怎么实现的。

2.1 maven依赖

<!-- 定义在facade接口模块的pom文件找那个 -->

<dependency>

    <groupId>javax.validation</groupId>

    <artifactId>validation-api</artifactId>

    <version>2.0.1.Final</version>

<!-- 如果不想facade包有多余的依赖,此处scope设为provided,否则可以删除 -->

    <scope>provided</scope>

</dependency>

<!-- 下面依赖通常加在Facade接口实现模块的pom文件中 -->

<dependency>

    <groupId>org.hibernate.validator</groupId>

    <artifactId>hibernate-validator</artifactId>

    <version>6.2.0.Final</version>

</dependency>


2.2 接口定义

facade接口定义:

public interface UserFacade {

    FacadeResult<Boolean> updateUser(UpdateUserParam param);

}

参数定义

public class UpdateUserParam implements Serializable {

    private static final long serialVersionUID = 2476922055212727973L;

    @NotNull(message = "用户标识不能为空")

    private Long id;

    @NotBlank(message = "用户名不能为空")

    private String name;

    @NotBlank(message = "用户手机号不能为空")

    @Size(min = 8, max = 16, message="电话号码长度介于8~16位")

    private String phone;

    // getter and setter ignored

}

公共返回定义

/**

* Facade接口统一返回结果

*/


2.3 Dubbo服务提供者端配置

Dubbo服务提供者端必须作这个validation="true"的配置,具体示例配置如下:

Dubbo接口服务端配置

<bean class="com.xxx.demo.UserFacadeImpl" id="userFacade"/>

<dubbo:service interface="com.xxx.demo.UserFacade" ref="userFacade" validation="true" />

Dubbo服务消费者端配置

这个根据业务方使用习惯不作强制要求,但建议配置上都加上validation="true",示例配置如下:

<dubbo:reference id="userFacade" interface="com.xxx.demo.UserFacade" validation="true" />


2.5 验证参数校验

前面几步完成以后,验证这一步就比较简单了,消费者调用该约定接口,接口入参传入UpdateUserParam对象,其中字段不用赋值,然后调用服务端接口就会得到如下的参数异常提示:

Dubbo接口服务端配置

javax.validation.ValidationException: Failed to validate service: com.xxx.demo.UserFacade, method: updateUser, cause: [ConstraintViolationImpl{interpolatedMessage='用户名不能为空', propertyPath=name, rootBeanClass=class com.xxx.demo.UpdateUserParam, messageTemplate='用户名不能为空'}, ConstraintViolationImpl{interpolatedMessage='用户手机号不能为空', propertyPath=phone, rootBeanClass=class com.xxx.demo.UpdateUserParam, messageTemplate='用户手机号不能为空'}, ConstraintViolationImpl{interpolatedMessage='用户标识不能为空', propertyPath=id, rootBeanClass=class com.xxx.demo.UpdateUserParam, messageTemplate='用户标识不能为空'}]

javax.validation.ValidationException: Failed to validate service: com.xxx.demo.UserFacade, method: updateUser, cause: [ConstraintViolationImpl{interpolatedMessage='用户名不能为空', propertyPath=name, rootBeanClass=class com.xxx.demo.UpdateUserParam, messageTemplate='用户名不能为空'}, ConstraintViolationImpl{interpolatedMessage='用户手机号不能为空', propertyPath=phone, rootBeanClass=class com.xxx.demo.UpdateUserParam, messageTemplate='用户手机号不能为空'}, ConstraintViolationImpl{interpolatedMessage='用户标识不能为空', propertyPath=id, rootBeanClass=class com.xxx.demo.UpdateUserParam, messageTemplate='用户标识不能为空'}]

    at org.apache.dubbo.validation.filter.ValidationFilter.invoke(ValidationFilter.java:96)

    at org.apache.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke(ProtocolFilterWrapper.java:83)

    ....

    at org.apache.dubbo.remoting.exchange.support.header.HeaderExchangeHandler.received(HeaderExchangeHandler.java:175)

    at org.apache.dubbo.remoting.transport.DecodeHandler.received(DecodeHandler.java:51)

    at org.apache.dubbo.remoting.transport.dispatcher.ChannelEventRunnable.run(ChannelEventRunnable.java:57)

    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)

    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)

    at java.lang.Thread.run(Thread.java:748)


三:定制Dubbo参数校验异常返回

从前面内容我们可以很轻松的验证,当消费端调用Dubbo服务时,参数如果不合法就会抛出相关异常信息,消费端调用时也能识别出异常信息,似乎这样就没有问题了。

但从前面所定义的服务接口来看,一般业务开发会定义统一的返回对象格式(如前文示例中的FacadeResult),对于业务异常情况,会约定相关异常码并结合相关性信息提示。因此对于参数校验不合法的情况,服务调用方自然不希望服务端抛出一大段包含堆栈信息的异常信息,而是希望还保持这种统一的返回形式,就如下面这种返回所示:

Dubbo接口服务端配置:

{

  "code": 1001,

  "msg": "用户名不能为空",

  "data": null

}

3.1 ValidationFilter & JValidator

想要做到返回格式的统一,我们先来看下前面所抛出的异常是如何来的?

从异常堆栈内容我们可以看出这个异常信息返回是由ValidationFilter抛出的,从名字我们可以猜到这个是采用Dubbo的Filter扩展机制的一个内置实现,当我们对Dubbo服务接口启用参数校验时(即前文Dubbo服务配置中的validation="true"),该Filter就会真正起作用,我们来看下其中的关键实现逻辑:

@Override

public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {

    if (validation != null && !invocation.getMethodName().startsWith("$")

            && ConfigUtils.isNotEmpty(invoker.getUrl().getMethodParameter(invocation.getMethodName(), VALIDATION_KEY))) {

        try {

            Validator validator = validation.getValidator(invoker.getUrl());

            if (validator != null) {

                // 注1

                validator.validate(invocation.getMethodName(), invocation.getParameterTypes(), invocation.getArguments());

            }

        } catch (RpcException e) {

            throw e;

        } catch (ValidationException e) {

            // 注2

            return AsyncRpcResult.newDefaultAsyncResult(new ValidationException(e.getMessage()), invocation);

        } catch (Throwable t) {

            return AsyncRpcResult.newDefaultAsyncResult(t, invocation);

        }

    }

    return invoker.invoke(invocation);

}

从前文的异常堆栈信息我们可以知道异常信息是由上述代码「注2」处所产生,这边是因为捕获了ValidationException,通过走读代码或者调试可以得知,该异常是由「注1」处valiator.validate方法所产生。

而Validator接口在Dubbo框架中实现只有JValidator,这个通过idea工具显示Validator所有实现的UML类图可以看出(如下图所示),当然调试代码也可以很轻松定位到。


既然定位到JValidator了,我们就继续看下它里面validate方法的具体实现,关键代码如下所示:

@Override

public void validate(String methodName, Class<?>[] parameterTypes, Object[] arguments) throws Exception {

    List<Class<?>> groups = new ArrayList<>();

    Class<?> methodClass = methodClass(methodName);

    if (methodClass != null) {

        groups.add(methodClass);

    }

    Set<ConstraintViolation<?>> violations = new HashSet<>();

    Method method = clazz.getMethod(methodName, parameterTypes);

    Class<?>[] methodClasses;

    if (method.isAnnotationPresent(MethodValidated.class)){

        methodClasses = method.getAnnotation(MethodValidated.class).value();

        groups.addAll(Arrays.asList(methodClasses));

    }

    groups.add(0, Default.class);

    groups.add(1, clazz);

    Class<?>[] classgroups = groups.toArray(new Class[groups.size()]);

    Object parameterBean = getMethodParameterBean(clazz, method, arguments);

    if (parameterBean != null) {

        // 注1

        violations.addAll(validator.validate(parameterBean, classgroups ));

    }

    for (Object arg : arguments) {

        // 注2

        validate(violations, arg, classgroups);

    }

    if (!violations.isEmpty()) {

        // 注3

        logger.error("Failed to validate service: " + clazz.getName() + ", method: " + methodName + ", cause: " + violations);

        throw new ConstraintViolationException("Failed to validate service: " + clazz.getName() + ", method: " + methodName + ", cause: " + violations, violations);

    }

}

从上述代码中可以看出当「注1」和注「2」两处代码进行参数校验时所得到的「违反约束」的信息都被加入到violations集合中,而在「注3」处检查到「违反约束」不为空时,就会抛出包含「违反约束」信息的ConstraintViolationException,该异常继承自ValidationException,这样也就会被ValidationFilter中方法所捕获,进而向调用方返回相关异常信息。

3.2 自定义参数校验异常返回

从前一小节我们可以很清晰的了解到了为什么会抛出那样的异常信息给调用方,如果想做到我们前面想要的诉求:统一返回格式,我们需要按照下面的步骤去实现。

3.2.1 自定义Filter

@Activate(group = {CONSUMER, PROVIDER}, value = "customValidationFilter", order = 10000)

public class CustomValidationFilter implements Filter {

    private Validation validation;

    public void setValidation(Validation validation) { this.validation = validation; }

    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {

        if (validation != null && !invocation.getMethodName().startsWith("$")

                && ConfigUtils.isNotEmpty(invoker.getUrl().getMethodParameter(invocation.getMethodName(), VALIDATION_KEY))) {

            try {

                Validator validator = validation.getValidator(invoker.getUrl());

                if (validator != null) {

                    validator.validate(invocation.getMethodName(), invocation.getParameterTypes(), invocation.getArguments());

                }

            } catch (RpcException e) {

                throw e;

            } catch (ConstraintViolationException e) {// 这边细化了异常类型

                // 注1

                Set<ConstraintViolation<?>> violations = e.getConstraintViolations();

                if (CollectionUtils.isNotEmpty(violations)) {

                    ConstraintViolation<?> violation = violations.iterator().next();// 取第一个进行提示就行了

                    FacadeResult facadeResult = FacadeResult.fail(ErrorCode.INVALID_PARAM.getCode(), violation.getMessage());

                    return AsyncRpcResult.newDefaultAsyncResult(facadeResult, invocation);

                }

                return AsyncRpcResult.newDefaultAsyncResult(new ValidationException(e.getMessage()), invocation);

            } catch (Throwable t) {

                return AsyncRpcResult.newDefaultAsyncResult(t, invocation);

            }

        }

        return invoker.invoke(invocation);

    }

}

该自定义filter与内置的ValidationFilter唯一不同的地方就在于「注1」处所新增的针对特定异常ConstraintViolationException的处理,从异常对象中获取包含的「违反约束」信息,并取其中第一个来构造业务上所定义的通用数据格式FacadeResult对象,作为Dubbo服务接口调用返回的信息。


3.2.2 自定义Filter的配置

开发过Dubbo自定义filter的同学都知道,要让它生效需要作一个符合SPI规范的配置,如下所示:


a. 新建两级目录分别是META-INF和dubbo,这个需要特别注意,不能直接新建一个目录名为「META-INFO.dubbo」,否则在初始化启动的时候会失败。

b. 新建一个文件名为com.alibaba.dubbo.rpc.Filter,当然也可以是org.apache.dubbo.rpc.Filter,Dubbo开源到Apache社区后,默认支持这两个名字。

c. 文件中配置内容为:customValidationFilter=com.xxx.demo.dubbo.filter.CustomValidationFilter。

3.3.3 Dubbo服务配置

有了自定义参数校验的Filter配置后,如果只做到这的话,其实还有一个问题,应用启动后会有两个参数校验Filter生效。当然可以通过指定Filter的order来实现自定义Filter先执行,但很显然这种方式不稳妥,而且两个Filter的功能是重复的,因此只需要一个生效就可以了,Dubbo提供了一种机制可以禁用指定的Filter,只需在Dubbo配置文件中作如下配置即可:

<!-- 需要禁用的filter以"-"开头并加上filter名称 -->

<!-- 查看源码,可看到需要禁用的ValidationFilter名为validation-->

<dubbo:provider filter="-validation"/>

但经过上述配置后,发现customValidationFilter并没有生效,经过调试以及对dubbo相关文档的学习,对Filter生效机制有了一定的了解。

a. dubbo启动后,默认会生效框架自带的一系列Filter;

可以在dubbo框架的资源文件org.apache.dubbo.rpc.Filter中看到具体有哪些,不同版本的内容可能会有些许差别。

cache=org.apache.dubbo.cache.filter.CacheFilter

validation=org.apache.dubbo.validation.filter.ValidationFilter  // 注1

echo=org.apache.dubbo.rpc.filter.EchoFilter

generic=org.apache.dubbo.rpc.filter.GenericFilter

genericimpl=org.apache.dubbo.rpc.filter.GenericImplFilter

token=org.apache.dubbo.rpc.filter.TokenFilter

accesslog=org.apache.dubbo.rpc.filter.AccessLogFilter

activelimit=org.apache.dubbo.rpc.filter.ActiveLimitFilter

classloader=org.apache.dubbo.rpc.filter.ClassLoaderFilter

context=org.apache.dubbo.rpc.filter.ContextFilter

consumercontext=org.apache.dubbo.rpc.filter.ConsumerContextFilter

exception=org.apache.dubbo.rpc.filter.ExceptionFilter

executelimit=org.apache.dubbo.rpc.filter.ExecuteLimitFilter

deprecated=org.apache.dubbo.rpc.filter.DeprecatedFilter

compatible=org.apache.dubbo.rpc.filter.CompatibleFilter

timeout=org.apache.dubbo.rpc.filter.TimeoutFilter

tps=org.apache.dubbo.rpc.filter.TpsLimitFilter

trace=org.apache.dubbo.rpc.protocol.dubbo.filter.TraceFilter

future=org.apache.dubbo.rpc.protocol.dubbo.filter.FutureFilter

monitor=org.apache.dubbo.monitor.support.MonitorFilter

metrics=org.apache.dubbo.monitor.dubbo.MetricsFilter

如上「注1」中的Filter就是我们上一步配置中想要禁用的Filter,因为这些filter都是Dubbo内置的,所以这些filter集合有一个统一的名字,default,因此如果想全部禁用,除了一个一个禁用外,也可以直接用'-default'达到目的,这些默认内置的filter只要没有全部或单独禁用,那就会生效。

b. 想要开发的自定义Filter能生效,不并一定要在<dubbo:provider filter="xxxFitler" >中体现;如果我们没有在Dubbo相关的配置文件中去配置Filter相关信息,只要写好自定义filter代码,并在资源文件/META-INF/dubbo/com.alibaba.dubbo.rpc.Filter中按照spi规范定义好即可,这样所有被加载的Filter都会生效。

c. 如果在Dubbo配置文件中配置了Filter信息,那自定义Filter只有显式配置才会生效。

d. Filter配置也可以加在dubbo service配置中(<dubbo:service interface="..." ref="..." validation="true" filter="xFilter,yFilter"/>)。

当dubbo配置文件中provider 和service部分都配置了Filter信息,针对service具体生效的Filter取两者配置的并集。

因此想要自定义的校验Filter在所有服务中都生效,需要作如下配置:

<dubbo:provider filter="-validation, customValidationFilter"/>


四、如何扩展校验注解

前面示例中都是利用参数校验的内置注解去完成,在实际开发中有时候会遇到默认内置的注解无法满足校验需求,这时就需要自定义一些校验注解去满足需求,方便开发。

假设有这样一个场景,某参数值需要校验只能在指定的几个数值范围内,类似于白名单一样,下面就以这个场景来演示下如何扩展校验注解。

4.1 定义校验注解

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })@Retention(RUNTIME)@Documented@Constraint(validatedBy = { })// 注1// @Constraint(validatedBy = {AllowedValueValidator.class}) 注2public@interfaceAllowedValue {    Stringmessage()default"参数值不在合法范围内";    Class[] groups()default{ };    Class[] payload()default{ };long[] value()default{}; }

publicclassAllowedValueValidatorimplementsConstraintValidator {privatelong[] allowedValues;@Overridepublicvoidinitialize(AllowedValue constraintAnnotation){this.allowedValues = constraintAnnotation.value();    }@OverridepublicbooleanisValid(Long value, ConstraintValidatorContext context){if(allowedValues.length ==0) {returntrue;        }returnArrays.stream(allowedValues).anyMatch(o -> Objects.equals(o, value));    }}

「注1」中的校验器(Validator)并没有指定,当然是可以像「注2」中那样直接指定校验器,但考虑到自定义注解有可能是直接暴露在facade包中,而具体的校验器的实现有时候会包含一些业务依赖,所以不建议直接在此处指定,而是通过Hibernate Validator提供的Validator发现机制去完成关联。

4.2 配置定制Validator发现


a. 在resources目录下新建META-INF/services/javax.validation.ConstraintValidator文件。

b. 文件中只需填入相应Validator的全路径:com.xxx.demo.validator.AllowedValueValidator,如果有多个的话,每行一个。

五、总结

本文主要介绍了使用Dubbo框架时如何使用优雅点方式完成参数的校验,首先演示了如何利用Dubbo框架默认支持的校验实现,然后接着演示了如何配合实际业务开发返回统一的数据格式,最后介绍了下如何进行自定义校验注解的实现,方便进行后续自行扩展实现,希望能在实际工作中有一定的帮助。

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 230,106评论 6 542
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 99,441评论 3 429
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 178,211评论 0 383
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 63,736评论 1 317
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 72,475评论 6 412
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 55,834评论 1 328
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 43,829评论 3 446
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 43,009评论 0 290
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 49,559评论 1 335
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 41,306评论 3 358
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 43,516评论 1 374
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 39,038评论 5 363
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 44,728评论 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 35,132评论 0 28
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 36,443评论 1 295
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 52,249评论 3 399
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 48,484评论 2 379

推荐阅读更多精彩内容