Feign简介与Spring代理自动注入

Feign是一款Java的基于注解的HTTP客户端,主要在服务端使用较广,属于Netflix系列。本文从动态代理开始介绍,然后推广到如何分离硬编码,到最后基于Spring的扫描装配实现。

面向读者

  • 有一定的Feign, Retrofit, Mybatis等基础
  • 了解Spring,反射,动态代理等技术

读者将学到

  • Feign的关键代码位置
  • 如何持续改进项目,降低硬编码
  • 如何自己实现Spring的自动注入

Feign的简单介绍

注解与动态代理

Feign本身只是一个注解的Parser,并没有负载均衡的功能。它与Retrofit类似,通过注解这种外部DSL拼装出容易理解的HTTP请求,并通过JDK动态代理实现

feign.ReflectiveFeign#newInstance

默认的Handler实现

feign.InvocationHandlerFactory.Default#create

默认动态代理调用实现是

feign.SynchronousMethodHandler#invoke

网络请求客户端

网络传输Client是通过对OkHttp/自带/RxJava等客户端的包装实现,只用实现Client接口,就可以对业务进行定制。比如自带的如下

// 自带网络请求实现
feign.Client.Default#execute

再比如SpringCloud中的负载均衡客户端如下

// Ribbon负载均衡
org.springframework.cloud.netflix.feign.ribbon.LoadBalancerFeignClient#execute

总的来说,有读过Retrofit的人再看这个难度不大,相反由于使用了Java8,代码量进一步减小

上述过程可以参考Retrofit的相关资料,比如参考这里

使用Feign一步步简化业务硬编码

在IT业务开发中,一般会涉及到与其它小组进行对接,其它组可能并没有用Eureka,而是采用了传统的HTTP接口。这时问题就来了,如果有多个业务需要拉通,就需要维护多个URL,传统的手段是采用多个HTTPClient类进行拼装与解析JSON,但是效率非常低(比如某些外包,拿着上万的工资,宁可复制20份url,写20个HTTPClient,也不想办法去思考改进,害人害己,30岁就废了),这个代码例子就不举例了。

对此,我们可以参考MybatisSpring的Mapper搜索,SpringCloud等源码中的实现方法,并进行学习吸收,实现简化与第三方的拉通。

使用Feign干掉模版代码

假如说,我们现有业务系统需要集成对接一个HTTP DNS的服务

# 下为DNSPod的例子
$ curl http://119.29.29.29/d?dn=gitbook.com
104.25.212.20;104.25.213.20  

现在我们可以仿照Feign官网的例子,写一个Demo

DNSPodService service = Feign.builder().decoder(new Decoder() {
    @Override
    public List<String> decode(Response response, Type type) throws IOException, DecodeException, FeignException {
        // 下面没有校验,为了节约版面
        String s = Util.toString(response.body().asReader());
        if (s.contains(";")) {
            return Arrays.asList(s.split(";"));
        }
        return Collections.singletonList(s);
    }
}).target(DNSPodService.class, "http://119.29.29.29");
List<String> ipInfo = service.getIpInfo("gitbook.io");

接口如下

// 下面的dn是硬编码,不要在意这些细节,因为我没有引入复杂的Encoder
public interface DNSPodService {
    @RequestLine("GET /d?dn={domain}")
    List<String> getIpInfo(@Param("domain") String domain);
}

这样,我们的一个服务就完成了,当接口中的方法比较多时,相对于纯HTTPClient的写法,这种收益就比较明显了。

通过注解替换Java中的地址硬编码

上面的代码中的Url是在Java中硬编码写死的,这样肯定不好维护。我们考虑加入如下注解

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface HTTPUrl {
    String value() default "";
}

然后加到接口中

@HTTPUrl("http://119.29.29.29")
public interface DNSPodService {
	...
}

接着,使用反射获取值

HTTPUrl anno = DNSPodService.class.getAnnotation(HTTPUrl.class);
// 此处可以用CucurrentHashMap<Class,String>来做缓存
String url = anno.value();
// 下文省略了Decoder
Feign.builder().target(DNSPodService.class, url)

通过注解标记,看起来更加连贯,读者心理上阅读代码会更加顺畅,看到这个接口就能想到有哪些服务需要维护。

通过Properties/配置中心/数据字典干掉注解硬编码

虽然这样修改有了一定改进,但是这样做仍然没有解决硬编码的问题,我们首先修改接口为如下

+ @HTTPUrl("dnspod.addr")
- @HTTPUrl("http://119.29.29.29")
public interface DNSPodService {

}

接着新增一个命名服务的接口

// 此接口将读取"dnspod.addr",并返回真正的url
public interface Resolvable {
    String resolve(String key);
}

然后我们先实现一个基于properties的实现类

public class PropertiesResolvable implements  Resolvable {
    Environment env;
    public PropertiesResolvable(Environment env) {
        this.env = env;
    }
    @Override
    public String resolve(String key) {
        return env.getProperty(key);
    }
}

然后在application.properties中配置好这个kv,接着跑起来

// 你的SpringBoot项目实现CommandLineRunner接口
@Autowired
Environment env;

@Override
public void run(String... args) throws Exception {
    HTTPUrl anno = DNSPodService.class.getAnnotation(HTTPUrl.class);
    String value = anno.value();
    Resolvable resolvable = new PropertiesResolvable(env);
    String realAddr = resolvable.resolve(value);
    // 下文省略了Decoder
    DNSPodService service = Feign.builder().target(DNSPodService.class, realAddr);
    List<String> ipInfo = service.getIpInfo("gitbook.io");
    System.out.println("ipInfo = " + ipInfo);
}

可以发现,目前我们实现了将硬编码管理转移到了Properties上,同时没有丢失Java代码的可读性

使用配置中心/数据字典的例子同上,只要实现了Resolvable接口即可热部署。

使用Spring与扫描器实现自动注册

制定目标----通过@Autowired自动生成Feign实例

我们希望通过自动/主动装配实现Feign自动生成接口

@Autowired
private DNSPodService service;
//然后直接使用

在本文开始时,曾经讲过可以参考Mybatis扫描Mapper的形式,或者SpringCloud的形式实现Feign的自动注入。

比如Mybatis通过如下注解实现注入

org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration.AutoConfiguredMapperScannerRegistrar

再比如SpringCloud中通过如下注解实现注入

org.springframework.cloud.netflix.feign.FeignClientsRegistrar#registerFeignClients

准备工作

我们首先仿照Mybatis等框架,写一个@Enable的注解

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(ClientRegistrar.class)
public @interface EnableScanFeign {
    String path() default "";
}

并注解到Application中

@SpringBootApplication
@EnableScanFeign(path = "com.example.demo.feign")
public class DemoApplication{}

@import这个注解除了直接注入各种Config外,也可以使用自定义的Selector

接着实现ClientRegistrar,它将在启动时读取注解的上下文


public class ClientRegistrar implements ImportBeanDefinitionRegistrar {

    @Override
    public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
        String path = ((String) metadata.getAnnotationAttributes(EnableScanFeign.class.getName()).get("path"));
        ResolverUtil<Class<?>> resolverUtil = new ResolverUtil<Class<?>>();
        resolverUtil.findAnnotated(HTTPUrl.class, path);
        Set<Class<? extends Class<?>>> classes = resolverUtil.getClasses();
        System.out.println("classes = " + classes);
    }
}

这样如果顺利的话,你就可以看到被扫描的接口了,不过目前这些只是接口,而没有实现类

为了降低文章难度,我们这里借用了Mybatis的VFS工具类,因此暂时需要导入Mybatis的包,你可以学习扫描jar包是如何实现的。此外,还有更简单的ClassPathMapperScanner或者更简单的ClassPathScanningCandidateComponentProvider也可以学习一个

Bean的注册流程

在Spring第三方库的开发中,我们可以从中学到常见的注册方法如下

// 接着上文的System.out来写
classes.stream()
    .map(this::generateHolder)// todo 生成BeanDefinitionHolder
    .forEach(holder ->
         BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry)
);

其中generateHolder就是我们抽出来的需要完善的方法,本文没有直接用Spring自带的来生成

配置FactoryBean

FactoryBean也是一种Bean,在真实项目中一般定制的就是这里

public static class ClientFactoryBean implements FactoryBean {

    // 这里通过properties写入
    private Class type;
    private String path;

    public Class getType() {
        return type;
    }
    public void setType(Class type) {
        this.type = type;
    }
    public String getPath() {
        return path;
    }
    public void setPath(String path) {
        this.path = path;
    }
    @Override
    public Object getObject() throws Exception {
        return Feign.builder()
            .decoder(/*同上*/)
            .target(DNSPodService.class, path);
    }
    @Override
    public Class<?> getObjectType() {
        return type;
    }
    @Override
    public boolean isSingleton() {
        return false;
    }
}

BeanHolder的实现

这里其实很简单,就是首先构造一个beanDefinition,接着通过property传递参数给Factory即可

// 带 Aware 的继承,都可以在生命周期中获得某个上下文对象
public class ClientRegistrar implements ImportBeanDefinitionRegistrar, EnvironmentAware {

    Environment environment;

    @Override
    public void setEnvironment(Environment environment) {
        this.environment = environment;
    }

    // 除了样板代码外,主要就是传递property
    private BeanDefinitionHolder generateHolder(Class<? extends Class<?>> aClass) {
        BeanDefinitionBuilder beanDefinitionBuilder = BeanDefinitionBuilder.genericBeanDefinition(ClientFactoryBean.class);
        beanDefinitionBuilder.addPropertyValue("type", aClass);
        String value = aClass.getAnnotation(HTTPUrl.class).value();
        beanDefinitionBuilder.addPropertyValue("path", environment.getProperty(value));
        BeanDefinition beanDefinition = beanDefinitionBuilder.getBeanDefinition();
        return new BeanDefinitionHolder(beanDefinition, aClass.getName());
    }
}

测试用例

测试如下,发现只要是Autowired即可自动注解

@SpringBootApplication
@EnableScanFeign(path = "com.example.demo.feign")
public class DemoApplication implements ApplicationRunner {

    @Autowired
    DNSPodService service;

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
    
    @Override
    public void run(ApplicationArguments args) throws Exception {
        List<String> ipInfo = service.getIpInfo("www.qq.com");
        System.out.println("ipInfo = " + ipInfo);
    }
}