闲鱼业务代码解耦利器SWAK是如何实现的(内含大量代码)

avatar
@阿里巴巴集团

作者:闲鱼技术——尘萧

三年前,我们发表了一篇文章给大家介绍了业务代码解构利器SWAK,SWAK是Swiss Army Knife的简称,众所周知,瑞士军刀是一款小巧灵活、适用于多种场景的工具。在闲鱼服务端,SWAK框架也是这样一种小巧灵活、适用于多种场景的技术框架, 它可以用于解决平台代码和业务代码耦合严重难以分离;业务和业务之间代码交织缺少拆解的问题。之前我们将其应用在了商品域的业务解耦上,现在我们在搜索的胶水层中再一次遇到了这样的问题,所以我们决定将SWAK重新再拿出来让其再放光彩。

SWAK在闲鱼搜索上的应用

目前闲鱼搜索采用了新的端云一体开发模式,引入了胶水层把客户端的一部分逻辑移动到了服务端,以此来提升端侧的动态能力,降低跟版本需求的数量,加快需求的上线速度。在运行这个新模式的过程中,一开始胶水层部分的代码结构设计较为简单,导致胶水层的代码出现严重的耦合以及if-else逻辑膨胀的情况。 undefined

我们基于对业务的预判,进行了胶水层的重构,主要通过SWAK解决了两个问题:

由于入搜的垂直业务不断增加,现有结构无法支撑业务进行自定义,所以通过SWAK进行了一层解耦,根据不同的bizType(业务类型)索引到相应的页面编排逻辑。保障了不同业务的页面编排逻辑之间不会相互影响,小业务可以复用商品搜索的页面逻辑,也可以自己进行定制。

卡片种类越来越多,导致了卡片解析器部分的if-else代码膨胀。所以我们引入了第二层SWAK,将卡片解析器部分的if-else去掉,改为通过cardType(卡片类型)索引到相应解析器的模式,避免后续的同学在一堆if-else中晕头转向,找不到真正的逻辑。

undefined

搜索胶水层目前还在整体的架构升级中,这边先给大家简单介绍一下,后续等架构升级完成后再写文章给大家介绍一下闲鱼搜索端云一体的研发模式,本文先着重介绍一下SWAK的实现原理。

回顾SWAK使用方式

在讲解SWAK的原理之前,我们先简单地回顾一下SWAK的使用方式,以便更好的理解它的原理。我们先看一下SWAK解决的是什么问题。举个例子当我们在进入搜索的时候,需要判断不同的搜索类型,并返回不同的页面编排,这时候就需要根据这个类型走到不同分支的代码了,如果代码耦合在一个地方,后续这个文件会变得越来越难以维护。

if(搜商品) { if(搜商品A版本) { doSomething1(); }else if(搜商品B版本) { doSomething2(); } } else if(搜会玩) { doSomething3(); } else if(搜用户) { if(搜用户A版本) { doSomething4(); }else if(搜用户B版本) { doSomething5(); } } SWAK的出现,就是为了解决上文中的这种情况,我们可以将所有的if-else对应的逻辑平铺开来,将其变为TAG,通过SWAK进行路由。

/**

•1.首先先定义一个接口•/ @SwakInterface(desc = "组件解析") // 使用注解声明这是一个多实现接口 public interface IPage { SearchPage parse(Object data); }

/**

•2.然后编写相应的实现,这个实现可以有很多个,用TAG进行标识•/ @Component @SwakTag(tags = {ComponentTypeTag.COMMON_SEARCH}) public class CommonSearchPage implements IPage { @Override public SearchPage parse(Object data) {

  return null;

} }

/**

3.编写Swak路由的入口

/ @Component public class PageService { @Autowired private IPage iPage;

@SwakSessionAop(tagGroupParserClass = PageTypeParser.class,

  instanceClass = String.class)

public SearchPage getPage(String pageType, Object data) {

  return iPage.parse(data);

} }

/**

•4.编写相应的解析类•/ public class PageTypeParser implements SwakTagGroupParser { @Override public SwakTagGroup parse(String pageType) {

      // pageType = ComponentTypeTag.COMMON_SEARCH
  return new SwakTagGroup.Builder().buildByTags(pageType);

} } 代码虽然没几行,但是覆盖了SWAK全部的核心流程,其中最核心的问题是SWAK如何找到相对应的接口实现,这个问题我们需要分为 注册过程 和 执行过程 两个部分来解答

图片2

注册过程

因为闲鱼服务端应用基本都基于Spring框架,所以SWAK在设计的时候就借用了很多Spring的特性。Spring相关的特性如果有不了解的可以自行进行查阅,这边就不进行详细介绍了。 以上面的例子为例,注册阶段主要的目的是找到@SwakInterface标注的IPage类并将其交给Spring容器进行托管,这样在使用的时候可以天然使用到Spring的依赖注入能力。同时为了后续能动态进行接口实现的替换,我们不能直接把找到的类注册到Spring容器中,我们需要将其hook成一个代理类,并在代理类中根据情况返回不同@SwakTag的实例。

这短短几句话中可能会产生几个疑问,我们一个一个来解答:

如何找到@SwakInterface标注的Bean 如何在Spring进行Bean注册的时候进行偷梁换柱 代理类怎么实现动态进行接口实现的替换 如何找到@SwakInterface标注的Bean

在JAVA中通常我们获取自定义注解的方式是使用反射,所以这里需要做的就是扫描所有的类(这边可以自行优化一下扫描范围,可以只扫描特定路径下的类)并且通过反射获取自定义注解。扫描库的代码自己编写逻辑当然是可以的,但是使用开源框架也是一个很好的选择,这边给大家推荐一下ronmamo的reflections库(Github),库的实现原理这边就不详细介绍了,使用方法也很简单,直接上代码吧

public Set<Class<?>> getSwakInterface() {
    Reflections reflections = new Reflections(new ConfigurationBuilder()
        .addUrls(ClasspathHelper.forPackage(this.packagePath))
        .setScanners(new TypeAnnotationsScanner(), new SubTypesScanner())
    );
    return reflections.getTypesAnnotatedWith(SwakInterface.class);
}

除了扫描@SwakInterface之外,我们也应该把 @SwakTag对应的类也扫出来,并将其存在一个map中,保证我们后面可以通过Tag去找到一个Class。

如何在Spring进行Bean注册的时候进行偷梁换柱

这个口子其实Spring已经给我们都准备好了,Spring在Bean的注册阶段会获取容器中所有类型为BeanDefinitionRegistryPostProcessor的bean,并调用postProcessBeanDefinitionRegistry方法,所以我们可以直接继承这个类并重写相应的方法来Hook这一流程。在这个方法中,我们可以创建一个新的BeanDefinition并将准备好的代理类作为BeanClass设置进去,这样生成对应的Bean时,就会直接使用到我们准备好的代理类了。(这里的原理涉及到Spring Bean的注册过程,可以自行查阅资料,不再详述)

@Configuration public class ProxyBeanDefinitionRegister implements BeanDefinitionRegistryPostProcessor {

@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry beanDefinitionRegistry) throws BeansException {
    Set<Class> typesAnnotatedWith = getSwakInterface();

    for (Class superClass : typesAnnotatedWith) {
        if (!superClass.isInterface()) {
            continue;
        }

        RootBeanDefinition beanDefinition = new RootBeanDefinition();
        beanDefinition.setBeanClass(SwakInterfaceProxyFactoryBean.class);

        beanDefinition.getPropertyValues().addPropertyValue("swakInterfaceClass", superClass);
        String beanName = superClass.getName();
        beanDefinition.setPrimary(true);
        beanDefinitionRegistry.registerBeanDefinition(beanName, beanDefinition);
    }
}

} 代理类怎么实现动态进行接口实现的替换

在上一步中,我们准备了一个SwakInterfaceProxyFactoryBean作为代理类注册到了BeanDefinitionMap中,但其实SwakInterfaceProxyFactoryBean严格意义上来说并不是一个代理类,正如它名字所描述的它是一个FactoryBean,FactoryBean是Spring中用来创建比较复杂的bean的一个类,在这个类的getObject()方法中,我们真正地使用动态代理的方式创建相应的对象,创建出相应的对象。

在动态代理方式的选择上,我们使用了 cglib 实现动态代理,因为JDK中自带的动态代理机制只能代理实现接口的类,而cglib可以为没有实现接口的类提供代理并且能够提供更好的性能。cglib的介绍网上有很多,这边就不详细介绍了。在Enhancer中设置一个CallBack,在这个被代理的类调用方法的时候,就会回调我们设置进去的SwakInterfaceProxy.intercept()方法进行拦截。intercept()方法我们放到下面执行过程中再进行详细介绍,先看看这部分代码

public class SwakInterfaceProxyFactoryBean implements FactoryBean { @Override public Object getObject() { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(this); this.clazz = clazz; // 这里一般不用new出来,可以把SwakInterfaceProxy也交给Spring进行托管,这里为了表述清晰用new指代一下 enhancer.setCallback(new SwakInterfaceProxy()); // 返回代理对象,返回的对象起始就是一个封装了"实体类"的代理类,是实现类的实例。 return enhancer.create(); } } 执行过程

在执行过程中,我们的主要目标是在@SwakSessionAop标记的方法体执行前,通过SwakTagGroupParser 根据参数解析出成员变量IPage iPage对应的实现类CommonSearchPage,之后在这个方法体中调用ipage.parse()就会直接调用到CommonSearchPage.parse()方法了。

同样是短短的两句话,那么有的小朋友可能会问了:

怎么在@SwakSessionAop标记的方法体执行前插入我们解析的代码呢 解析出对应的实现类后,是怎么"赋值"给iPage变量的 如何在方法前面插入代码

看到这个问题自然而然地想到,这不就是要做一个AOP嘛,这玩意Spring都帮我们搞定了,Spring的AOP是通过cglib实现的基于JVM的动态代理,并做了一层很好用的封装。我们可以使用@Around注解在方法前进行一层切面来执行我们的代码,我们先使用SwakTagGroupParser解析tagGroup并将解析出来的tagGroup存起来,然后可以调用jointPoint.proceed()继续执行方法体,这样在方法体中所使用到的iPage就会使用到相应的实现了。

可能有的人会有疑问,这里不就是把tagGroup存了一下吗?这么后面就会使得iPage使用相应的实现呢?关于这个我们在下一个问题中进行描述。

@Component @Aspect public class SwakSessionInterceptor {

@Pointcut("@annotation(com.taobao.idle.swak.core.aop.SwakSessionAop)")
public void sessionAop() {
}


@Around("sessionAop()&&@annotation(swakSessionAop)")
public Object execute(ProceedingJoinPoint jointPoint, SwakSessionAop swakSessionAop) {
    // 根据类型获取需要传入Parser的参数
    Class instanceClass = swakSessionAop.instanceClass();
    Object sessionInstance;
    for (Object object : args) {
        if (instanceClass.isAssignableFrom(object.getClass())) {
            sessionInstance = object;
        }
    }

    //通过Parser解析出相应的tagGroup
    Class parserClass = swakSessionAop.tagGroupParserClass();
    SwakTagGroupParser swakTagGroupParser = (SwakTagGroupParser)(parserClass.newInstance());
    SwakTagGroup tagGroup = swakTagGroupParser.parse(sessionInstance);

    try {
        //SwakSessionHolder就是一个储存tagGroup的地方,可以随意实现
        SwakSessionHolder.hold(tagGroup);
        Object object = jointPoint.proceed();
        return object;
    } finally {
        SwakSessionHolder.clear();
    }
}

} 如何"赋值"iPage变量

首先我需要解释一下为什么一直在给"赋值"打引号,因为这部分确实不是真的去给iPage赋值,但是达到的效果是一样的。还记得之前我们把@SwakInterface所标注的类在注册的时候做了一层动态代理,所以iPage对应的对象在调用方法前,都会调用一下之前提到的intercept()方法,在这个方法中,我们可以通过之前存起来的tagGroup找到需要调用的SwakTag,并通过SwakTag找到相应的实现类的实例,最后通过method.invoke()方法调用其实例。

关于反射的相关API这里就不详细介绍了,引用一下廖雪峰对于Method的解释:对Method实例调用invoke就相当于调用该方法,invoke的第一个参数是对象实例,即在哪个实例上调用该方法,后面的可变参数要与方法参数一致,否则将报错。 public class SwakInterfaceProxy implements MethodInterceptor { @Override public Object intercept(Object o, Method method, Object[] parameters, MethodProxy methodProxy) throws Throwable { String interfaceName = clazz.getName(); SwakTagGroup tagGroup = SwakSessionHolder.getTagGroup();

    // 这里还可以根据tag的优先级配置调整执行顺序,这里就简单取一下
    List<String> tags = tagGroup.getTags();
    Object retResult = null;
    try {
        // 按照优先级依次执行
        for (String tag : tags) {
            // 根据TAG可以获取到实现类的实例
            // 可能第一次用,那么没有实例只有Class,拿Class去Spring容器里找对应的实例
            Object tagImplInstance = getInvokeInstance(tag);
            retResult = method.invoke(tagImplInstance, parameters);
        }
        return retResult;
    } catch (Throwable t) {
        throw t;
    }
}

} 至此,一次完整的使用SWAK调用方法的流程就完成了。

总结

本文重点对SWAK的原理进行了阐述,同时贴上了部分关键代码实现,文中涉及的部分代码,为了减少理解成本和篇幅做了一定程度的删减,切忌直接拷贝使用。SWAK开源准备工作仍然任重道远,可能短时间内无法与大家见面,但是大家可以参考本文来自己进行实现。如果文章发出后,大家有疑问,我们也会根据大家的疑问继续写相应的文章解答。