likes
comments
collection
share

SpringMvc项目集成nacos、openfeign、Ribbon,仿 springcloud openfeign 实现微服务下接口调用

作者站长头像
站长
· 阅读数 24

SpringMvc项目集成nacos、openfeign、Ribbon,仿 springcloud openfeign 实现微服务下接口调用

背景

近几年,公司新开发项目转为微服务架构,但有很多基于 SpringMvc 老系统,若都进行系统重构会消耗很大的人力、时间成本。故尝试在 SpringMvc 系统中通过集成 nacosfeign 的方式让老系统焕发第二春。

已知

1、nacos官方已提供SpringMvc集成示例
2、openfeign基于feign的微服务架构下服务之间调用解决方案,官方只提供了Spring Cloud版本

问题

1、公司当前SpringMvc项目基于Spring 4.x版本,尝试对Spring版本升级发现存在大量问题,本人能力有限故放弃。
2、SpringMvc项目为独立单体项目,存在独立的用户权限配置体系。

分析

1、nacos官方已提供了SpringMvc集成示例
2、openfeign虽没有SpringMvc版本,但好在作为开源项目,有项目源码可以参考

实现

SpringMvc集成nacos

添加依赖

<dependency>
   <groupId>com.alibaba.nacos</groupId>
   <artifactId>nacos-spring-context</artifactId>
   <version>{nacos.version}</version>
   <exclusions>
      <exclusion>
         <groupId>org.springframework</groupId>
         <artifactId>spring-context</artifactId>
      </exclusion>
   </exclusions>
</dependency>

spring-context与项目中引用的有冲突,故排除。 通过添加 @EnableNacosDiscovery 注解开启 Nacos Spring 的服务发现功能:

@Configuration
@EnableNacosDiscovery(globalProperties = @NacosProperties(serverAddr = "127.0.0.1:8848"))
public class NacosConfiguration {

}

注意:按照 nacos 官方集成到 spring 的例子配置后会发现 nacos 管理端可以查看到服务,但是一会就消失了,怀疑是 spring 服务未定时发送心跳链接导致。 查看nacos源代码中发送心跳链接部分:

# BeatReactor.java
private final ScheduledExecutorService executorService;

public BeatReactor(NamingProxy serverProxy, int threadCount) {
    this.serverProxy = serverProxy;
    this.executorService = new ScheduledThreadPoolExecutor(threadCount, new ThreadFactory() {
        @Override
        public Thread newThread(Runnable r) {
            Thread thread = new Thread(r);
            thread.setDaemon(true);
            thread.setName("com.alibaba.nacos.naming.beat.sender");
            return thread;
        }
    });
}

/**
 * Add beat information.
 *
 * @param serviceName service name
 * @param beatInfo    beat information
 */
public void addBeatInfo(String serviceName, BeatInfo beatInfo) {
    NAMING_LOGGER.info("[BEAT] adding beat: {} to beat map.", beatInfo);
    String key = buildKey(serviceName, beatInfo.getIp(), beatInfo.getPort());
    BeatInfo existBeat = null;
    //fix #1733
    if ((existBeat = dom2Beat.remove(key)) != null) {
        existBeat.setStopped(true);
    }
    dom2Beat.put(key, beatInfo);
    executorService.schedule(new BeatTask(beatInfo), beatInfo.getPeriod(), TimeUnit.MILLISECONDS);
    MetricsMonitor.getDom2BeatSizeMonitor().set(dom2Beat.size());
}

BeatReactor 在构造器中实例化了一个 ScheduledThreadPoolExecutor 在调用注册方法(addBeatInfo)时创建定时任务,在给定的延时后给 nacos 发送心跳信息

ScheduledThreadPoolExecutor 可参考:定时任务ScheduledThreadPoolExecutor的使用详解

class BeatTask implements Runnable {

    BeatInfo beatInfo;

    public BeatTask(BeatInfo beatInfo) {
        this.beatInfo = beatInfo;
    }

    @Override
    public void run() {
        if (beatInfo.isStopped()) {
            return;
        }
        long nextTime = beatInfo.getPeriod();
        try {
            JsonNode result = serverProxy.sendBeat(beatInfo, BeatReactor.this.lightBeatEnabled);
            long interval = result.get("clientBeatInterval").asLong();
            boolean lightBeatEnabled = false;
            if (result.has(CommonParams.LIGHT_BEAT_ENABLED)) {
                lightBeatEnabled = result.get(CommonParams.LIGHT_BEAT_ENABLED).asBoolean();
            }
            BeatReactor.this.lightBeatEnabled = lightBeatEnabled;
            if (interval > 0) {
                nextTime = interval;
            }
            int code = NamingResponseCode.OK;
            if (result.has(CommonParams.CODE)) {
                code = result.get(CommonParams.CODE).asInt();
            }
            if (code == NamingResponseCode.RESOURCE_NOT_FOUND) {
                Instance instance = new Instance();
                instance.setPort(beatInfo.getPort());
                instance.setIp(beatInfo.getIp());
                instance.setWeight(beatInfo.getWeight());
                instance.setMetadata(beatInfo.getMetadata());
                instance.setClusterName(beatInfo.getCluster());
                instance.setServiceName(beatInfo.getServiceName());
                instance.setInstanceId(instance.getInstanceId());
                instance.setEphemeral(true);
                try {
                    serverProxy.registerService(beatInfo.getServiceName(),
                            NamingUtils.getGroupName(beatInfo.getServiceName()), instance);
                } catch (Exception ignore) {
                }
            }
        } catch (NacosException ex) {
            NAMING_LOGGER.error("[CLIENT-BEAT] failed to send beat: {}, code: {}, msg: {}",
                    JacksonUtils.toJson(beatInfo), ex.getErrCode(), ex.getErrMsg());

        }
        # 循环发送心跳信息
        executorService.schedule(new BeatTask(beatInfo), nextTime, TimeUnit.MILLISECONDS);
    }
}

BeatTask#run 方法中可以看到在执行 registerService 后会重复创建定时任务以达到在特定时间重复向 nacos 注册服务信息。

综上可知,spring 服务想要持续向 nacos 发送心跳信息,需手动调用一次nacos的实例注册方法,nacos 配置类修改为:

/**
 * @author: kkfan
 * @create: 2021-07-08 15:54:44
 * @description: nacos 配置
 */
@Configuration
@EnableNacosDiscovery(globalProperties = @NacosProperties)
// 加载 nacos 服务配置信息
@PropertySource(value = "classpath:nacos.properties")
public class NacosConfiguration {

    @Value("${nacos.group-name:PLATFORM-01}")
    private String groupName;

    @Value("${server.port}")
    private String port;

    @Value("${nacos.service-name:platform1}")
    private String serviceName;

    @NacosInjected
    private NamingService namingService;

    @NacosInjected(properties = @NacosProperties(encode = "UTF-8"))
    private NamingService namingServiceUTF8;

    @PostConstruct
    public void init() {
        try {
            InetAddress address = InetAddress.getLocalHost();
            if (namingService != namingServiceUTF8) {
                throw new RuntimeException("nacos service registration failed");
            } else {
                namingService.registerInstance(serviceName, groupName, address.getHostAddress(), Integer.parseInt(port));
            }
        } catch (UnknownHostException | NacosException e) {
            e.printStackTrace();
        }
    }

}
  • @NacosInjected 是一个核心注解,用于在 Spring Beans 中注入ConfigServiceNamingService 实例,并使这些实例可缓存。 这意味着如果它们的 @NacosProperties 相等,则实例将是相同的,无论属性是来自全局还是自定义的 Nacos 属性。参考:Nacos Spring

spring 集成 openfeign

openfeign 是一种声明式的web服务客户端,在 spring cloud 中,仅需创建一个接口并对其进行几行注释即可实现调用远程服务就像调用本地方法一样,开发者完全感知不到是在调用远程方法,更没有像 HttpClient 那样相对繁琐的请求参数封装与响应解析。但遗憾的是官方只提供了 Spring Cloud 版本。本文将参照 spring-cloud-openfeignspring mvc 项目中使用 feign 实现远程服务的调用。

本文参考 spring-cloud-starter-openfeign 版本为 2.0.0.RELEASE,以下简称 openfeign

spring-cloud-openfeign 源码分析

  1. 从开启 openfeign 服务注解 @EnableFeignClients 开始
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(FeignClientsRegistrar.class)
public @interface EnableFeignClients {

   ...
}

EnableFeignClientsspringIOC 容器导入了一个 FeignClientsRegistrar 实例。

class FeignClientsRegistrar implements ImportBeanDefinitionRegistrar,
      ResourceLoaderAware, EnvironmentAware {
            
}

FeignClientsRegistrar 实现了 ImportBeanDefinitionRegistrar 接口,使用 @Import,如果括号中导入的类是 ImportBeanDefinitionRegistrar 的实现类,则会调用接口方法 registerBeanDefinitions,将其中要注册的类注册成 bean

@Override
public void registerBeanDefinitions(AnnotationMetadata metadata,
      BeanDefinitionRegistry registry) {
   // 注册默认配置
   registerDefaultConfiguration(metadata, registry);
   // 注册 feignClients
   registerFeignClients(metadata, registry);
}

BeanDefinitionRegistryspring 中动态注册 beanDefinition 的接口。

registerDefaultConfiguration 用来注册 EnableFeignClients 中提供的自定义配置类中的 Bean,我们主要来看 registerFeignClients

public void registerFeignClients(AnnotationMetadata metadata,
      BeanDefinitionRegistry registry) {
   // 类扫描
   ClassPathScanningCandidateComponentProvider scanner = getScanner();
   scanner.setResourceLoader(this.resourceLoader);
   // 存储类扫描路径
   Set<String> basePackages;
   // 获取EnableFeignClients注解属性
   Map<String, Object> attrs = metadata
         .getAnnotationAttributes(EnableFeignClients.class.getName());
   // 注解filter -> FeignClient
   AnnotationTypeFilter annotationTypeFilter = new AnnotationTypeFilter(
         FeignClient.class);
   // 获取EnableFeignClients上是否配置clients属性
   final Class<?>[] clients = attrs == null ? null
         : (Class<?>[]) attrs.get("clients");
   // if ... else 主要是确定类扫描路径和添加扫描过滤器
   if (clients == null || clients.length == 0) {
      // 类路径扫描器添加过滤器
      scanner.addIncludeFilter(annotationTypeFilter);
      // 获取EnableFeignClients上配置的扫描路径 若不存在则获取EnableFeignClients类所在路径
      basePackages = getBasePackages(metadata);
   }
   // 若配置了clients
   else {
      final Set<String> clientClasses = new HashSet<>();
      basePackages = new HashSet<>();
      // 获取 clients 配置类所在的包路径
      for (Class<?> clazz : clients) {
         basePackages.add(ClassUtils.getPackageName(clazz));
         clientClasses.add(clazz.getCanonicalName());
      }
      // 定义filter 根据给定的 ClassMetadata 对象确定匹配项。
      AbstractClassTestingTypeFilter filter = new AbstractClassTestingTypeFilter() {
         @Override
         protected boolean match(ClassMetadata metadata) {
            String cleaned = metadata.getClassName().replaceAll("\$", ".");
            return clientClasses.contains(cleaned);
         }
      };
      // 添加filter
      scanner.addIncludeFilter(
            new AllTypeFilter(Arrays.asList(filter, annotationTypeFilter)));
   }
   // 开始根据包路径扫描 FeignClient
   for (String basePackage : basePackages) {
      // 扫描 FeignClient bean 定义
      Set<BeanDefinition> candidateComponents = scanner
            .findCandidateComponents(basePackage);
      for (BeanDefinition candidateComponent : candidateComponents) {
         // 判断类是否为带注解的Bean
         if (candidateComponent instanceof AnnotatedBeanDefinition) {
            // 验证注解类是否是一个接口(注意是接口)
            AnnotatedBeanDefinition beanDefinition = (AnnotatedBeanDefinition) candidateComponent;
            AnnotationMetadata annotationMetadata = beanDefinition.getMetadata();
            Assert.isTrue(annotationMetadata.isInterface(),
                  "@FeignClient can only be specified on an interface");
            // 获取FeignClient上配置的属性
            Map<String, Object> attributes = annotationMetadata
                  .getAnnotationAttributes(
                        FeignClient.class.getCanonicalName());
            // 获取 FeignClient 定义名称
            String name = getClientName(attributes);
            registerClientConfiguration(registry, name,
                  attributes.get("configuration"));
            # 注册 feign client
            registerFeignClient(registry, annotationMetadata, attributes);
         }
      }
   }
}

注意: FeignClient 注解标注的是接口 registerFeignClients 方法主要是为了获取 FeignClient 注解标注的接口

下面看注册 FeignClient 方法:

private void registerFeignClient(BeanDefinitionRegistry registry,
      AnnotationMetadata annotationMetadata, Map<String, Object> attributes) {
   // 利用 BeanDefinitionBuilder 向 spring 容器中注入 bean
      
   String className = annotationMetadata.getClassName();
   
   // 这里要注意 FeignClientFactoryBean 将会在集成 ribbon 说明
   BeanDefinitionBuilder definition = BeanDefinitionBuilder.genericBeanDefinition(FeignClientFactoryBean.class);
   
   ...
   
   AbstractBeanDefinition beanDefinition = definition.getBeanDefinition();

   ...

   // 到此完成了从 FeignClient 注释的接口到 BeanDefinition 转化
   BeanDefinitionHolder holder = new BeanDefinitionHolder(beanDefinition, className,
         new String[] { alias });
   // 将转化后的 BeanDefinition 注入 spring 容器
   BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry);
}

到此 openfeign 完成了将 FeignClient 注解注释的接口信息注入通过 BeanDefinition 注入 spring 容器。

仿 openfeign 实现 FeignClient 接口发现与注册

  1. openfeign 中复制以下源码修改:

SpringMvc项目集成nacos、openfeign、Ribbon,仿 springcloud openfeign 实现微服务下接口调用

  1. 仿照 openfeignFeignClientsConfiguration 添加 FeignConfig 配置类
/**
 * @author: kkfan
 * @create: 2021-07-08 15:54:44
 * @description: feign 配置
 */
@Configuration
@EnableFeignClients(basePackages = "com.kk.feign")
public class FeignConfig {

    public FeignConfig() {
        try {
            // ribbon全局配置读入
            ConfigurationManager.loadPropertiesFromResources("ribbon.properties");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @NacosInjected
    private NamingService namingService;

    @Value("${nacos.group-name:PLATFORM-01}")
    private String groupName;

    @Bean
    public static FeignContext feignContext() {
        return new FeignContext();
    }

    @Bean
    public FeignLoggerFactory feignLoggerFactory() {
        return new DefaultFeignLoggerFactory(null);
    }

    @Bean
    public Feign.Builder feignBuilder(Retryer retryer) {
        return Feign.builder()
                .retryer(retryer);
    }

    @Bean
    public Retryer feignRetryer() {
        return Retryer.NEVER_RETRY;
    }

    @Bean
    public Decoder feignDecoder() {
        return new JacksonDecoder();
    }

    @Bean
    public Encoder feignEncoder() {
        return new JacksonEncoder();
    }

    @Bean
    public Contract feignContract() {
        return new Contract.Default();
    }

    @Bean
    public FeignClientProperties feignClientProperties() {
        return new FeignClientProperties();
    }

    @Bean
    public Targeter feignTargeter() {
        return new Targeter.DefaultTargeter();
    }
 
}

至此完成了 feign 的集成,但还存在以下问题:

  1. FeignClient 注解类中的 SpringMvc 的注解不支持;
  2. 未和 nacos 集成使用,只能在 FeignClient 中指明调用地址。

下面来解决上面两个问题:

  1. 支持 SpringMvc 注解 参考 openfeign 中的 SpringMvcContract 把相关代码拷出来,相关代码如下:

SpringMvc项目集成nacos、openfeign、Ribbon,仿 springcloud openfeign 实现微服务下接口调用

注意由 spring 版本不同导致的兼容问题

修改 FeignConfig#feignContract 如下:

@Bean
public Contract feignContract() {
    return new SpringMvcContract();
}
  1. feign + nacos 集成 这部分实现主要为从 nacos 中获取已注册服务列表,feign 根据在 FeignClient 上配置的服务名来调用对应的服务,这部分将在下一节关于集成 ribbon 实现负载均衡中体现。

集成Ribbon

在集成完 nacos + feign 后下一个问题是 nacosfeign 都集成好了,如何把他们合在一起使用呢,我们接着看在上节中注册 feignClient 是说到的 FeignClientFactoryBean

class FeignClientFactoryBean
      implements FactoryBean<Object>, InitializingBean, ApplicationContextAware {
    ...
}

其实现了 FactoryBean 接口,我们知道如果要使用 Bean 工厂,可以手动实现一个 FactoryBean 的类,改接口有三个方法如下:

public interface FactoryBean<T> {
    String OBJECT_TYPE_ATTRIBUTE = "factoryBeanObjectType";

    @Nullable
    T getObject() throws Exception;

    @Nullable
    Class<?> getObjectType();

    default boolean isSingleton() {
        return true;
    }
}

其中 isSingleton 是用来判断生产的 bean 是否是单例,有默认实现,我们不需要手动实现。getObject 方法是获得生产出来的 bean 对象,getObjectType 是用于获得生产对象的类。

现在来找下 FeignClientFactoryBeangetObject 的实现,代码如下:

@Override
public Object getObject() throws Exception {
	return getTarget();
}

/**
 * @param <T> the target type of the Feign client
 * @return a {@link Feign} client created with the specified data and the context
 * information
 */
<T> T getTarget() {
	FeignContext context = this.applicationContext.getBean(FeignContext.class);
	Feign.Builder builder = feign(context);

	if (!StringUtils.hasText(this.url)) {
		if (!this.name.startsWith("http")) {
			this.url = "http://" + this.name;
		}
		else {
			this.url = this.name;
		}
		this.url += cleanPath();
		return (T) loadBalance(builder, context,
				new HardCodedTarget<>(this.type, this.name, this.url));
	}
	...
}

可以看到调用了一个 loadBalance 方法,从字面意思上看负载均衡,应该就是想要的,接着往下看:

protected <T> T loadBalance(Feign.Builder builder, FeignContext context,
		HardCodedTarget<T> target) {
	Client client = getOptional(context, Client.class);
	if (client != null) {
		builder.client(client);
		Targeter targeter = get(context, Targeter.class);
		return targeter.target(this, builder, context, target);
	}

	throw new IllegalStateException(
			"No Feign Client for loadBalancing defined. Did you forget to include spring-cloud-starter-netflix-ribbon?");
}

该方法接收一个 feign builder 和一个 feign context,打个断点调试下这段代码:

SpringMvc项目集成nacos、openfeign、Ribbon,仿 springcloud openfeign 实现微服务下接口调用 可以看到 getOption 从上下文中获取了一个 Client 实例 LoadBalancerFeignClient 后添加到 feign builder 中,现在问题就解决了,在 spring 集成 openfeign 一节中有创建 feignBuilder,在其中加入ribbon client 即可,代码如下:

@Bean
public Feign.Builder feignBuilder(Retryer retryer) {
    return Feign.builder()
            .retryer(retryer)
            .client(ribbonClient())
            .requestInterceptor(new KkRequestInterceptor(new ObjectMapper()));
}

/**
 * 构建负载均衡
 * @return
 */
private RibbonClient ribbonClient() {
    return RibbonClient.builder().lbClientFactory(clientName -> {
        log.info("初始化客户端: ---------》" + clientName);
        IClientConfig config = ClientFactory.getNamedConfig(clientName);

//            ZoneAwareLoadBalancer zb = new ZoneAwareLoadBalancer(config, zoneAvoidanceRule(), ribbonPing(), ribbonServerList(), ribbonServerListFilter(), ribbonServerListUpdater());
        ILoadBalancer lb = ClientFactory.getNamedLoadBalancer(clientName);
        ZoneAwareLoadBalancer zb = (ZoneAwareLoadBalancer) lb;
        zb.setRule(zoneAvoidanceRule());
        zb.setServersList(getByServerName(clientName));
        return LBClient.create(zb, config);
    }).build();
}

其中 ribbon 负载均衡策略如下:

/**
 * Ribbon负载均衡策略实现
 * 使用ZoneAvoidancePredicate和AvailabilityPredicate来判断是否选择某个server,前一个判断判定一个zone的运行性能是否可用,
 * 剔除不可用的zone(的所有server),AvailabilityPredicate用于过滤掉连接数过多的Server。
 * @return
 */
private IRule zoneAvoidanceRule() {
    return new ZoneAvoidanceRule();
}

可用服务列表根据服务名称从nacos中读取:

/**
 * 从nacos读取服务, 封装节点
 * @param name
 * @return
 */
private List<Server> getByServerName(String name) {
    List<Server> servers = new ArrayList<>();
    try {
        List<Instance> allInstances = namingService.getAllInstances(name, groupName);
        allInstances.forEach(x -> {
            Server server = new Server(x.getIp(), x.getPort());
            server.setZone(name);
            servers.add(server);
        });
    } catch (NacosException e) {
        e.printStackTrace();
    }
    return servers;
}

集成完 ribbon 后至此就完成了 spring 集成 openfeign 中的 feign + nacos 集成小节。

测试

略,以上为本人测试通过后记录。

因本人能力有限,文中可能有很多不足之处,故谢绝转载,谢谢。

参考

www.cnblogs.com/dalianpai/p… blog.csdn.net/jll126/arti… blog.csdn.net/taiyangdao/… my.oschina.net/redking/blo… blog.csdn.net/menggudaoke… github.com/spring-clou… cofcool.github.io/tech/2019/0…

感谢以上大佬的分享。