Java业务校验工具实现

一、背景

在我们日常接口开发过程中,可能要面对一些稍微复杂一些的业务逻辑代码的编写,在执行真正的业务逻辑前,往往要进行一系列的前期校验工作,校验可以分为参数合法性校验业务数据校验

参数合法性校验比如最常见的校验参数值非空校验、格式校验、最大值最小值校验等,可以通过Hibernate Validator框架实现,本文不具体讲解。业务数据校验通常与实际业务相关,比如提交订单接口,我们可能需要校验商品是否合法、库存是否足够、客户余额是否足够、还有其他的一些风控校验。我们的代码可能看起来像是这样的:

public ApiResult<OrderSubmitVo> submitOrder(OrderSubmitDto orderSubmitDto) {
    // 业务校验1

    // 业务校验2

    // 业务校验3

    // 业务校验n...

    // 执行真正的业务逻辑

    return ApiResult.success();
}

二、问题

  • 实现不够优雅

上述代码在版本迭代的过程中,还可能陆陆续续增加/修改一些校验逻辑,如果业务逻辑校验的代码都耦合在核心业务逻辑中,这样实现其实是不够优雅,不符合设计原则的单一职责原则和开闭原则。

  • 校验代码无法复用

如果某个业务校验代码需要在其他业务中也会用到,那我们则需要将相同的代码复制一份至业务代码中,比如校验用户状态,在很多业务校验中都需要校验,如果校验逻辑有些许更改的话,那么所有涉及到的地方都要同步修改,这样不利于系统维护。

  • 校验逻辑无法按照顺序依赖执行,并且校验过程中产生的数据后续获取不便

如果我们将上述代码中的各个校验逻辑封装成独立的子方法,那有可能存在业务校验2要依赖于业务校验1的数据结果,并且在业务校验过程中产生的数据在后续执行真正的业务逻辑的时候是需要用得到的。

三、校验工具实现思路

我们要写的校验工具至少要解决上面所说的三个问题

  • 业务校验代码与核心业务逻辑代码解耦

  • 同一个校验器可以用于多个业务,提高代码的复用性和可维护性

  • 校验代码可以按照指定顺序执行,并且校验过程中产生的数据可以后续传递

在用zuul来做网关服务的时候,我获得了一些灵感,
zuul中的filterType用来区分请求路由到目标之前、处理目标请求、目标请求返回后的类型,filterOrder用来指定过滤器的执行顺序,RequestContext为请求上下文,RequestContext继承自ConcurrentHashMap,且与ThreadLocal绑定保证线程安全,请求上下文中的数据在一次请求的所有过滤器中可以获取,很好的完成了数据传递。

首先我们需要定义一个校验器注解,注解中指定业务类型和执行顺序,在校验器上加上该注解表明这是一个校验器。定义一个校验器上下文,在业务校验执行过程中产生的数据可以通过上下文进行传递。定义一个校验器基类,校验器继承基类,并实现其中的具体校验方法。定义一个校验器的统一执行器,执行器可以根据业务类型找出所有带有校验器注解并且是指定业务类型的校验器列表,根据校验器注解中的执行顺序排序后,遍历所有校验器列表调用校验方法。如果校验过程中校验失败,则抛出校验异常中断业务执行。

以上为大概的实现思路,具体的实现代码如下:

四、show me your code

  • Validator.java
import java.lang.annotation.*;

/**
 * @author: 会跳舞的机器人
 * @date: 2019/10/23 13:58
 * @description: 业务校验注解
 */
@Documented
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Validator {
    /**
     * 业务类型,同一个校验器可以指定多个业务类型
     *
     * @return
     */
    String[] validateTypes();

    /**
     * 执行顺序,数值越小越先执行
     *
     * @return
     */
    int validateOrder();
}

Validator校验注解,在校验器的类上加上该注解则表明为业务校验器,validateTypes表示业务类型,同一个校验器可以指定多个业务类型,多个业务类型可以复用同一个校验器,validateOrder表示执行顺序,数值越小越先被执行。

  • ValidatorContext.java

import java.util.concurrent.ConcurrentHashMap;

/**
 * @author: 会跳舞的机器人
 * @date: 2019/9/11 14:56
 * @description: 校验器上下文,与当前线程绑定
 */
public class ValidatorContext extends ConcurrentHashMap<String, Object> {
    /**
     * 请求对象
     */
    public Object requestDto;

    protected static final ThreadLocal<? extends ValidatorContext> threadLocal = ThreadLocal.withInitial(() -> new ValidatorContext());

    /**
     * 获取当前线程的上下文
     *
     * @return
     */
    public static ValidatorContext getCurrentContext() {
        ValidatorContext context = threadLocal.get();
        return context;
    }


    /**
     * 设值
     *
     * @param key
     * @param value
     */
    public void set(String key, Object value) {
        if (value != null) put(key, value);
        else remove(key);
    }


    /**
     * 获取String值
     *
     * @param key
     * @return
     */
    public String getString(String key) {
        return (String) get(key);
    }

    /**
     * 获取Integer值
     *
     * @param key
     * @return
     */
    public Integer getInteger(String key) {
        return (Integer) get(key);
    }

    /**
     * 获取Boolean值
     *
     * @param key
     * @return
     */
    public Boolean getBoolean(String key) {
        return (Boolean) get(key);
    }

    /**
     * 获取对象
     *
     * @param key
     * @param <T>
     * @return
     */
    public <T> T getClazz(String key) {
        return (T) get(key);
    }

    /**
     * 获取Long值
     *
     * @param key
     * @return
     */
    public Long getLong(String key) {
        return (Long) get(key);
    }


    public <T> T getRequestDto() {
        return (T) requestDto;
    }

    public void setRequestDto(Object requestDto) {
        this.requestDto = requestDto;
    }

ValidatorContext为请求上下文,与当前请求线程绑定,继承自ConcurrentHashMap,requestDto属性为接口请求入参对象,提供get/set方法使得在上下文中能更加便捷的获取请求入参数据。

  • ValidatorTemplate.java

/**
 * @author: 会跳舞的机器人
 * @date: 2019/10/23 11:51
 * @description: 校验器模板,业务校验器需继承模板类
 */
@Slf4j
@Component
public abstract class ValidatorTemplate {

    /**
     * 校验方法
     */
    public void validate() {
        try {
            validateInner();
        } catch (ValidateException e) {
            log.error("业务校验失败", e);
            throw e;
        } catch (Exception e) {
            log.error("业务校验异常", e);
            ValidateException validateException = new ValidateException(ResultEnum.VALIDATE_ERROR);
            throw validateException;
        }
    }

    /**
     * 校验方法,由子类具体实现
     *
     * @throws ValidateException
     */
    protected abstract void validateInner() throws ValidateException;
}

校验器抽象类,具体的校验器需要继承该类,并且实现具体的validateInner校验方法。

  • ValidatorTemplateProxy.java


/**
 * @author: 会跳舞的机器人
 * @date: 2019/10/25 18:03
 * @description: ValidatorTemplate代理类
 */
@Data
@AllArgsConstructor
public class ValidatorTemplateProxy extends ValidatorTemplate implements Comparable<ValidatorTemplateProxy> {
    private ValidatorTemplate validatorTemplate;
    private String validateType;
    private int validateOrder;

    @Override
    public int compareTo(ValidatorTemplateProxy o) {
        return Integer.compare(this.getValidateOrder(), o.getValidateOrder());
    }

    @Override
    protected void validateInner() throws ValidateException {
        validatorTemplate.validateInner();
    }
}

ValidatorTemplate类的代理类,实现了Comparable排序接口,便于校验器按照validateOrder属性排序,并且将校验器中的注解转化为代理类中的两个属性字段,方便执行过程中的统一日志打印。

  • ValidateProcessor.java
import java.lang.annotation.Annotation;
import java.util.*;

/**
 * @author: 会跳舞的机器人
 * @date: 2019/10/25 18:02
 * @description: 执行器
 */
@Slf4j
@Component
public class ValidateProcessor {

    /**
     * 执行业务类型对应的校验器
     *
     * @param validateType
     */
    public void validate(String validateType) {
        if (StringUtils.isEmpty(validateType)) {
            throw new IllegalArgumentException("validateType cannot be null");
        }
        long start = System.currentTimeMillis();
        log.info("start validate,validateType={},ValidatorContext={}", validateType, ValidatorContext.getCurrentContext().toString());
        List<ValidatorTemplateProxy> validatorList = getValidatorList(validateType);
        if (CollectionUtils.isEmpty(validatorList)) {
            log.info("validatorList is empty");
            return;
        }
        ValidatorTemplateProxy validateProcessorProxy;
        for (ValidatorTemplateProxy validatorTemplate : validatorList) {
            validateProcessorProxy = validatorTemplate;
            log.info("{} is running", validateProcessorProxy.getValidatorTemplate().getClass().getSimpleName());
            validatorTemplate.validate();
        }
        log.info("end validate,validateType={},ValidatorContext={},time consuming {} ms", validateType,
                ValidatorContext.getCurrentContext().toString(), (System.currentTimeMillis() - start));
    }


    /**
     * 根据Validator注解的validateType获取所有带有该注解的校验器
     *
     * @param validateType
     * @return
     */
    private List<ValidatorTemplateProxy> getValidatorList(String validateType) {
        List<ValidatorTemplateProxy> validatorTemplateList = new LinkedList<>();
        Map<String, Object> map = SpringUtil.getApplicationContext().getBeansWithAnnotation(Validator.class);
        String[] validateTypes;
        int validateOrder;
        Annotation annotation;
        for (Map.Entry<String, Object> item : map.entrySet()) {
            annotation = item.getValue().getClass().getAnnotation(Validator.class);
            validateTypes = ((Validator) annotation).validateTypes();
            validateOrder = ((Validator) annotation).validateOrder();
            if (item.getValue() instanceof ValidatorTemplate) {
                if (Arrays.asList(validateTypes).contains(validateType)) {
                    validatorTemplateList.add(new ValidatorTemplateProxy((ValidatorTemplate) item.getValue(), validateType, validateOrder));
                }
            } else {
                log.info("{}not extend from ValidatorTemplate", item.getKey());
            }
        }
        Collections.sort(validatorTemplateList);
        return validatorTemplateList;
    }
}

业务校验的执行器,getValidatorList方法根据validateType值获取所有带有该validateType值的校验器,并将其封装成ValidatorTemplateProxy代理类,然后再做排序。validate为统一的业务校验方法。

  • ValidateException.java


/**
 * @author: 会跳舞的机器人
 * @date: 2019/4/4 6:34 PM
 * @description: 校验异常
 */
public class ValidateException extends RuntimeException {
    // 异常码
    private Integer code;

    public ValidateException() {
    }

    public ValidateException(String message) {
        super(message);
    }

    public ValidateException(ResultEnum resultEnum) {
        super(resultEnum.getMsg());
        this.code = resultEnum.getCode();
    }

    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }

}

ValidateException为校验失败时,抛出的业务校验异常类。

  • ValidateTypeConstant.java

/**
 * @author: 会跳舞的机器人
 * @date: 2019/10/30 15:16
 * @description:
 */
public class ValidateTypeConstant {
    /**
     * 提交订单校验
     */
    public static final String ORDER_SUBMIT = "order_submit";
}

ValidateTypeConstant为定义validateType业务校验类型的常量类。

五、使用样例

以订单提交为例,我们首先定义了两个个基本的校验器,下单商品信息校验器、客户状态校验器,均为伪代码实现。

  • OrderSubmitProductValidator.java

/**
 * @author: 会跳舞的机器人
 * @date: 2019/10/30 15:34
 * @description: 商品状态以及库存校验
 */
@Component
@Slf4j
@Validator(validateTypes = ValidateTypeConstant.ORDER_SUBMIT, validateOrder = 1)
public class OrderSubmitProductValidator extends ValidatorTemplate {
    @Override
    protected void validateInner() throws ValidateException {
        ValidatorContext validatorContext = ValidatorContext.getCurrentContext();
        OrderSubmitDto orderSubmitDto = validatorContext.getRequestDto();
        // 获取商品信息并校验商品状态
        List<ProductShelfVo> productShelfVoList = new ArrayList<>();
        if (0 == 1) {
            throw new ValidateException("商品已下架");
        }
        // 将商品信息设置至上下文中
        validatorContext.set("productShelfVoList", productShelfVoList);
    }
}
  • OrderSubmitCustomerValidator.java

/**
 * @author: 会跳舞的机器人
 * @date: 2019/10/30 19:24
 * @description:
 */
@Component
@Slf4j
@Validator(validateTypes = ValidateTypeConstant.ORDER_SUBMIT, validateOrder = 2)
public class OrderSubmitCustomerValidator extends ValidatorTemplate {
    @Override
    protected void validateInner() throws ValidateException {
        ValidatorContext validatorContext = ValidatorContext.getCurrentContext();
        String customerNo = validatorContext.getString("customerNo");
        if (StringUtils.isEmpty(customerNo)) {
            throw new IllegalArgumentException("客户编号为空");
        }
        // 获取客户信息并校验客户状态
        CustomerVo customer = new CustomerVo();
        if (0 == 1) {
            throw new ValidateException("客户限制交易");
        }
    }
}

在提交订单的业务逻辑的代码中使用:

/**
 * 提交订单
 *
 * @param orderSubmitDto
 * @return
 */
public ApiResult<OrderSubmitVo> submitOrder(OrderSubmitDto orderSubmitDto) {
    // 业务校验
    ValidatorContext validatorContext = ValidatorContext.getCurrentContext();
    validatorContext.setRequestDto(orderSubmitDto);
    validateProcessor.validate(ValidateTypeConstant.ORDER_SUBMIT);
    // 从上下文中获取下单商品信息
    List<ProductShelfVo> productShelfVoList = validatorContext.getClazz("productShelfVoList");

    // 后续业务逻辑处理
    return ApiResult.success();
}

通过使用上述封装的校验工具后,业务代码与校验代码解耦,后续要增加/修改业务校验逻辑时候,我们只需要增加/修改相应的校验器即可,不必改动到主业务逻辑。为了我们能更简单和方便找到某个业务逻辑对应所有的校验器,我们在命名校验器的时候可以加上业务类型的前缀。

六、总结

1、在开发过程中,我们遇到一些“烦人”问题的时候,要想办法解决它,而不是忽略不管它,通过解决问题可以提高我们的技术能力。

2、要善于从其他优秀的技术框架学习其实现思路。

3、以上校验工具只是一个简单实现,解决的问题只是笔者在开发过程中遇到的问题,可能并不一定具有通用性。


如果文章对你有帮助的话,给文章点个赞吧。

如果有写得不正确的地方,欢迎指出。

文章首发公众号:会跳舞的机器人,欢迎扫码关注。

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