支付系统 - 通道服务的框架设计演化<二>

313 阅读6分钟

前言

本文是 通道服务的框架设计演化 续篇,上文中主要介绍了支付系统中通道服务的框架的设计演化过程,笔者最终给出了一版实现。最终选择了 服务收口 + 泛型 + 服务插件 + 工厂模式的设计。

简单回顾:

  • 服务收口
public interface IChannelService<T extends AbstractReqModel, R extends AbstractRspModel> {

    R invoke(T request) throws ChannelServiceException;
}
  • 通道服务工厂
public interface IChannelServiceFactory {

    /**
     * 通过通道和服务类别获取通道服务,未获取到时返回null
     */
    IChannelService getChannelService(ChannelEnum channel, ServiceTypeEnum serviceTypeEnum);

}

其中,服务插件只是工厂模式实现的一种手段。这种方式,需要在 XML 中进行配置,清晰但增加了一定的工作量。 毕竟对于写后端语言的同学而言,维护如下的 XML 还是有些麻烦。

此外,前文中对于服务类别的定义是这样的:

public enum ServiceIdEnum {

    //扫码支付
    SCAN_CODE("scan_code"),
    //APP支付
    APP("app"),
    //付款码支付
    BRUSH_CARD("brush_card"),
    //公众号支付
    GZ("gz"),
    //小程序支付
    MINI_PROGRAM("mini_program"),
    //手机网站支付
    MOBILE_H5("mobile_h5"),
    //电脑网站支付
    PC_WEB("pc_web"),

    //支付查询
    PAY_QUERY("pay_query"),
    //退款查询
    REFUND_QUERY("refund_query"),
    //支付通知
    PAY_NOTIFY("pay_notify"),

    //省略后续

可以看到,支付预下单具体到了支付产品,导致维护时需要在 XML 配置的更多。当初选择这样设计是为了避免某一种支付产品升级时不影响到其它支付产品。在实际的使用中,发现这种方式还是成本太高。因此,后续的设计中合并为预支付一个服务类别。当然为了避免影响,在程序开发中会显式的将不同支付产品的实现拆分为多个类,这也是一种 tradeoff

本文的重点即是以支付系统的背景阐述工厂方法的几种实现方式,抛砖引玉希望对各位有所帮助。

正文

插件通道服务工厂

通过解析XML + Spring bean注册 + 工厂方法完成服务的配置。此方式在注解还未流行的年代很是常用,如JAVA生态常用的Spring/Mybatis等框架较早期版本都是解析XML。优点也很明显,配置文件和代码分离,更易于维护,不易出错。相应的实现可参考前文 通道服务的框架设计演化

注解通道服务工厂

这里有多种实现方式,当然前提是都是用注解。要建立注解通道服务工厂,分为三步。

定义注解

既然我们是通过通道和服务类别来管理通道关系,因此,注解中这两个元素是必不可少的。这一步是以下的基础。

可以选择在自定义注解中标记@Component注解,由于注解的继承特性,被自定义注解标记的类也会被自动注册到 Spring 容器中。我们可以通过上下文容器方便的获取到所有被自定义注解标记的类。

/**
 * 通道服务枚举
 *
 * @author <a href="mailto:pleuvior@foxmail.com">pleuvoir</a>
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface ChannelService {

    /**
     * 通道
     */
    ChannelEnum channel();

    /**
     * 服务类别
     */
    ServiceTypeEnum serviceTypeEnum();

}

定义实现类

这样被我们的自定义注解标记,该类的功能即为模拟通道预下单服务。

/**
 * 模拟通道
 *
 * @author <a href="mailto:pleuvior@foxmail.com">pleuvoir</a>
 */
@ChannelService(channel = ChannelEnum.MOCK, serviceTypeEnum = ServiceTypeEnum.PAY)
public class MockPay implements IChannelService<PaymentDTO, PaymentResultDTO> {

    @Override
    public PaymentResultDTO invoke(PaymentDTO request) throws ChannelServiceException {
        return null;
    }
}

工厂类实现

首先第一件事情就是获取所有的通道实现类。这里有两种方式:

  • 使用容器方法获取所有实现类
  Collection<IChannelService> channelServices = applicationContext.getBeansOfType(IChannelService.class, false, false).values();
  • 使用 BeanDefinitionRegistryPostProcessor 前后置处理器
/**
 * 通道实现类别名注册
 *
 * @author <a href="mailto:pleuvior@foxmail.com">pleuvoir</a>
 */
@Component
public class ChannelBeanDefinitionRegistry implements BeanDefinitionRegistryPostProcessor {

    @Override
    public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {

    }

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
        String[] beans = beanFactory.getBeanNamesForAnnotation(ChannelService.class);
        if (beans.length > 0) {
            for (String bean : beans) {
                Class<?> clazz = beanFactory.getType(bean);
                ChannelService channelService = clazz.getAnnotation(ChannelService.class);
                String channelName = channelService.channel().name();
                String serviceType = channelService.serviceTypeEnum().getId();
                String beanName = channelName + serviceType + "Service";

                //防止别名覆盖bean的ID
                if (beanFactory.containsBeanDefinition(beanName)) {
                    return;
                } else {
                    beanFactory.registerAlias(bean, beanName);
                }

            }
        }
    }
}

这样自动注册的 bean name 会被我们重写,我们可以使用org.springframework.beans.factory.BeanFactory#getBean(java.lang.String, java.lang.Class<T>)方法来获取实现类。

关键点是注册别名时的规则,我们要考虑到用户调用 API 时可以拿到的参数,比如我们有通道信息,服务类别信息,那么注册别名时使用此关键要素即可。极端一点的例子,别名是随机生成的,我们自然就不可用了。

以下演示第一种实现,我们可以选择使用 Guava 包中的三元组 Table,如下示:

//通道、服务类别,实现类 
private Table<ChannelEnum, ServiceIdEnum, Class<?>> services = Tables.newCustomTable(new ConcurrentHashMap<>(), ConcurrentHashMap::new);

通过 Spring 的初始化回调函数注册到三元组中。

public void afterPropertiesSet(){
    Collection<IChannelService> channelServices = applicationContext.getBeansOfType(IChannelService.class, false, false).values();

    channelServices.forEach(channelService -> {
        Class<? extends IChannelService> clazz = channelService.getClass();
        if (clazz.isAnnotationPresent(ChannelService.class)) {
            ChannelService service = clazz.getAnnotation(ChannelService.class);
            ChannelEnum channelEnum = service.channel();
            ServiceTypeEnum serviceTypeEnum = service.serviceTypeEnum();
            channelServiceTable.put(channelEnum, serviceTypeEnum, channelService);
            log.info("初始化通道服务,{} -> {}", channelEnum.getCode(), serviceTypeEnum.getId());
        }
    });
}

如此,我们现在可以通过如下方式拿到对应的实现类。

IChannelService channelService = channelServiceTable.get(channel, serviceType);

总结

第一步定义注解,第二步实现类和注解关联,第三步获取所有的实现类完成工厂的注册。以上演示的例子中均是在 Spring 容器中,实际可能的情况是工程不在容器环境中。我们也可以使用包扫描工具获取所有的实现类来完成我们的注册逻辑。

枚举映射通道服务工厂

这是一种静态配置的方式,优点是足够简单易于理解,缺点是如果需要维护的配置类过多,那么该类会变得过于臃肿,在修改时可能容易出错。如果维护的服务类可以预见不是特别多,推荐使用此种方式

笔者亲自经历过这样的线上事故,因为某开发人员粗心大意新增配置时不小心改动了某原有支付通道的配置类,导致新增加的通道在测试环境验证无误,但旧通道却无法使用。当然,这种问题其实是人为因素导致,可以通过 Code Review 来降低此问题的影响程度。毕竟,大意可能无法避免但可以减少。相对于上文中分开维护的 XML 文件,或者是注解类,这种实现确实可能出问题的概率大一些。

比较简单,直接放代码吧。

/**
 * 枚举映射通道服务工厂
 *
 * @author <a href="mailto:pleuvior@foxmail.com">pleuvoir</a>
 */
@Slf4j
public class MappingChannelServiceFactory implements IChannelServiceFactory {

    enum Mapping {

        MOCK(ChannelEnum.MOCK, ServiceTypeEnum.PAY, MockPay.class);

        @Getter
        private ChannelEnum channel;
        @Getter
        private ServiceTypeEnum serviceType;
        @Getter
        private Class<? extends IChannelService> channelService;


        Mapping(ChannelEnum channel, ServiceTypeEnum serviceType, Class<? extends IChannelService> channelService) {
            this.channel = channel;
            this.serviceType = serviceType;
            this.channelService = channelService;
        }

        public static Class<? extends IChannelService> mapping(ChannelEnum channel, ServiceTypeEnum serviceType) {
            if (channel == null || serviceType == null) {
                return null;
            }
            for (Mapping mapping : Mapping.values()) {
                if (mapping.getChannel().equals(channel) && mapping.getServiceType().equals(serviceType)) {
                    return mapping.getChannelService();
                }
            }
            return null;
        }
    }


    @Override
    public IChannelService getChannelService(ChannelEnum channel, ServiceTypeEnum serviceType) {
        Class<? extends IChannelService> clazz = Mapping.mapping(channel, serviceType);
        if (clazz == null) {
            log.error("获取通道服务失败,枚举未配置,channel={},serviceType={}", channel, serviceType);
            return null;
        }
        return ApplicationContextHelper.getBean(clazz);
    }

}

其实这个方法,就没有必要实现 IChannelServiceFactory 接口了,因为我们在最后获取 bean 的时候使用了 ApplicationContextHelper 静态获取方式,这个类使用了在容器初始化回调接口 ApplicationContextAware 中设置了上下文方便我们使用静态方法获取容器。

这样我们其实完全可以使用静态方法获取通道服务实现类,否则还需要将当前的工厂类注册到 Spring 容器中,各有利弊,看自己的需要即可。

后语

后顾茫茫,距离发布第一版通道服务的设计演化已经过去两年有余。在这两年的过程中,逐渐有了一些新的思考。由于个人设计经验的总结,难免有不足之处,如果有不合理、可以改进之处,还望指正,期盼探讨,谢谢大家。