命令、模板模式结合 Spring 在后端接口项目中的一次实践

913 阅读5分钟

最近一年断断续续接触了几个新的项目,都由自己进行编码框架设计。恰恰这几个项目接口设计形式都大同小异,HTTP、嵌套 JSON、统一网关路径、命令码分发等。慢慢的,在这种形式的后端项目的框架设计和编码上有了一些自己的心得和体会。

下面是接口大致形式:

// 请求
{
    "mchId" : "xxx",
    "version" : "1.0",
    "signType" : "SHA256",
    "timeStamp" : "xxx",
    "bizType": "",
    "bizContent" : {
        "xxx":"xxx",
        "xxx":"xxx"
    },
    "sign":""
}

// 响应
{
    "gateCode":"",
    "gateMsg":"",
    "bizCode":"",
    "bizMsg":"",
    "sign":""
}

不同的业务使用 bizType 字段进行区分,如:query、pay 等,不同的 bizType 对应着不同的 bizContent

Command 类设计

public abstract class AbsCommand<T extends OpenApiBizContentVo> {

    public OpenApiBaseResponse progress(String ori) {
        // 解析请求原始报文为 JavaBean
        OpenApiBaseRequestVo<T> reqInstance = null;
        try {
            reqInstance = JSON.parseObject(ori, new TypeReference<OpenApiBaseRequestVo<T>>(this.getBizContentClass()) {});
        }
        catch (Exception e) {
            //
        }

        // 省略验签

        // 调用业务处理方法
        return this.progress(reqInstance);
    }

    public abstract OpenApiBaseResponse progress(OpenApiBaseRequestVo<T> context);

    public abstract Class<T> getBizContentClass();
}

这是最基本的骨架,统一进行请求操作,统一进行命令分发,并采用 虚方法 getBizContentClass,让子类返回实际对应 bizContent 类型,加上 fastjson 框架对泛型的支持解决嵌套 JSON 解析问题。

假如这时候有 bizType 为 query 的命令码,那么可以这么实现:

public class Query extends AbsCommand<OpenApiQueryBizContentVo>{

    @Override
    public OpenApiBaseResponse progress(OpenApiBaseRequestVo<OpenApiQueryBizContentVo> context) {
        // do something...
        return null;
    }

    @Override
    public Class<OpenApiQueryBizContentVo> getBizContentClass() {
        return OpenApiQueryBizContentVo.class;
    }
}

命令处理器与实体类都是类型强关联,省去了强转操作,非常方便和优雅。

与 Spring 结合

说简单点,就是给 bizType 的值和托管在 SpringIOC 容器中 AbsCommand 实例做一次映射,Spring 提供了可以根据某类型拿到该类型所有示例的方法。

这样子就非常好办了,在 虚基类 中添加一个方法返回当前命令处理器所支持的所有 biztype 集合,子类实现之。再监听 Spring 容器的初始化事件,拿到所有 处理器实例,同时也拿到了该实例支持的命令列表,最后做一次映射即可。

转换为代码如下:

AbsCommand 类添加获取支持命令码列表方法

public abstract class AbstractCommand<T extends OpenApiBizContentVo> {
    
    // 省略其他代码...
    
    public abstract Set<OpenApiCommandCodeEnum> supports();
}

新建 CommandFactory 类,做命令码和 AbsCommand 类型在 Spring 中托管的实例之间的映射

@Slf4j
@Component
public class CommandFactory implements ApplicationListener<ContextRefreshedEvent> {

    private static Map<String, AbstractCommand<?>> commandInstanceMapping = new HashMap<>();

    public void register(String command, AbstractCommand<?> instance) {
        commandInstanceMapping.put(command, instance);
    }

    public AbstractCommand<?> getCommandInstance(String command) {
        return commandInstanceMapping.get(command);
    }

    // 消除原生态类型警告
    @SuppressWarnings("rawtypes")
    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        ApplicationContext applicationContext = event.getApplicationContext();

        Map<String, AbstractCommand> payerBeanMap = applicationContext.getBeansOfType(AbstractCommand.class);

        for (Map.Entry<String, AbstractCommand> entry : payerBeanMap.entrySet()) {
            AbstractCommand<?> command = entry.getValue();

            Set<OpenApiCommandCodeEnum> supports = command.supports();

            if (CollectionUtils.isEmpty(supports)) {
                log.error("类型:{} 支持命令码为空,不注册", command.getClass().getSimpleName());
            }
            
            for (OpenApiCommandCodeEnum commandCodeEnum : supports) {
                log.info("命令码:{} 注册实例:{}", commandCodeEnum.getValue(), command.getClass().getSimpleName());
                register(commandCodeEnum.getValue(), command);
            }
        }
    }
}

OpenApiCommandCodeEnum 为所有 bizType 值的集合。 监听 Spring 容器的 ContextRefreshedEvent 事件,等容器初始化完对各个 AbsCommand 实例 进行自定义映射。当需要获取实例时,调用 getCommandInstance 方法即可。

此时,controller 层(调用方)就可以这么写:

@Slf4j
@Controller
@RequestMapping("/")
public class OpenApiController {

    @Autowired
    private CommandFactory commandFactory;

    @PostMapping(value = {"gateway"}, produces = MediaType.APPLICATION_JSON_VALUE)
    @ResponseBody
    public OpenApiBaseResponse open(HttpServletRequest request) throws BindException {
        String ori = super.readAsString(request);
        
        // ...

        String commandCode = JSONObject.parseObject(ori).getString("bizType");
        if (StringUtils.isEmpty(commandCode)) {
            return OpenApiBaseResponse.gatewayFail(OpenApiResultCodeEnum.PARAM_ERROR, "命令码为空", "");
        }

        // 
        AbstractCommand<? extends OpenApiBizContent> abstractCommand = commandFactory.getCommandInstance(commandCode);
        if (abstractCommand == null) {
            return OpenApiBaseResponse.gatewayFail(OpenApiResultCodeEnum.PARAM_ERROR, "命令码非法", "");
        }

        return abstractCommand.progress(ori);
    }
}

优化1:模板方法

由于 AbsCommand 类已经做了一次代码层 网关处理,模板方法就非常好做,比如某个业务想在实际方法 执行前 做一些初始化动作或是实际方法 执行后 做一些东西,那这里就可以这么做:

@Slf4j
public abstract class AbstractCommand<T extends OpenApiBizContent> {

    public OpenApiBaseResponse progress(String ori) throws BindException  {

        // ... 省略

        if (!this.before(reqInstance)) {
            return OpenApiBaseResponse.gatewayFail(OpenApiResultCodeEnum.OTHER, "拦截失败", mchId);
        }

        OpenApiBaseResponse response = this.progress(reqInstance);

        return this.after(reqInstance, response);
    }

    /**
     * 做一些具体业务后的操作,子类可以选择复写
     */
    private OpenApiBaseResponse after(OpenApiBaseRequestVo<T> req, OpenApiBaseResponse response) {
        return response;
    }

    /**
     * 做一些具体业务前的操作,子类可以选择复写
     * 返回 false 不往下走,返回网关失败;返回 true 往下走
     */
    protected boolean before(OpenApiBaseRequestVo<T> req) {
        return true;
    }

    public abstract OpenApiBaseResponse progress(OpenApiBaseRequestVo<T> req);
    
    // ... 省略
}

新建 beforeafter 方法,使每个命令码处理器都有权利去自定义业务前后的操作。

优化2:参数包装

有时候某个处理器需要的参数不只是请求的 OpenApiBaseRequestVo 类,可能还需要一些 公用的本地配置 需要获取。若按照现在的框架,则都需要在各自的命令处理器中实现。

所以为了方便,可以将 OpenApiBaseRequestVo 类再包装一层,就像这样子:

@Data
public class AppContext<T extends OpenApiBizContentVo> {

    private OpenApiBaseRequestVo<T> request;

    private MchConfig mchConfig;

    private SysConfig sysConfig;

    public AppContext(OpenApiBaseRequestVo<T> request, MchConfig mchConfig, SysConfig sysConfig) {
        this.request = request;
        this.mchConfig = mchConfig;
        this.sysConfig = sysConfig;
    }
}

然后在 AbsCommand 中统一进行配置的获取操作,如下:

@Slf4j
public abstract class AbstractCommand<T extends OpenApiBizContentVo> {

    public OpenApiBaseResponse progress(String ori) throws BindException  {

        // ... 省略
        
        // 获取操作
        MchConfig mchConfig = null; 
        SysConfig sysConfig = null; 
        
        AppContext<T> context = new AppContext<>(reqInstance, mchConfig, sysConfig);

        if (!this.before(context)) {
            return OpenApiBaseResponse.gatewayFail(OpenApiResultCodeEnum.OTHER, "拦截失败", mchId);
        }

        OpenApiBaseResponse response = this.progress(context);

        return this.after(context, response);
    }


    private OpenApiBaseResponse after(AppContext<T> context, OpenApiBaseResponse response) {
        return response;
    }


    protected boolean before(AppContext<T> context) {
        return true;
    }

    public abstract OpenApiBaseResponse progress(AppContext<T> context);
    
    // ... 省略
}

优化3:参数校验

这点与标题不相符,但还是觉得有必要提一下。针对字段联合校验这种需求这时候我们是没有比较好的方法。但此时我们已经统一了 bizContent 的类型都为 OpenApiBizContentVo,所以可以在这里下手,添加 check 虚方法,让子类去实现,如下:

public interface OpenApiBizContentVo {
    void check() throws BindException;
}

同时在包装类 OpenApiBaseRequestVo 中也添加 check 方法

@Data
public class OpenApiBaseRequestVo<T extends OpenApiBizContentVo> {

    // ... 省略

    @Length(max = 32)
    @NotBlank
    @SpecialCharacter
    private String timeStamp;
    
    @Valid
    private T bizContent;

    @JSONField(serialize = false)
    public void check() throws BindException {
        ValidationUtil.validate(this);
        bizContent.check();
    }
}

此时就可以在 AbsCommand中统一调用。

public abstract class AbsCommand<T extends OpenApiBizContentVo> {

    public OpenApiBaseResponse progress(String ori) {
    
        OpenApiBaseRequestVo<T> reqInstance = null;
        try {
            reqInstance = JSON.parseObject(ori, new TypeReference<OpenApiBaseRequestVo<T>>(this.getBizContentClass()) {});
        }
        catch (Exception e) {
            //
        }
        
        reqInstance.check();

        // ... 省略

    }
}

如果不懂 SpringValidation 可以看下我的另一篇文章:基于 SpringValidation 的参数校验较佳实践

小结

假如此时你有三个组员,如果按照这个架子进行开发,就可以达到互不影响。因为公共的操作都已经被框架做了,只需要填一些业务代码即可。(如果返回需要签名:可以采用 Spring 提供的 ControllerAdvice 技术)