天天看點

SpringMvc項目仿 springcloud openfeign 實作微服務下接口調用

作者:是啊超ya

SpringMvc項目內建nacos、openfeign、Ribbon,仿 springcloud openfeign 實作微服務下接口調用

背景

近幾年,公司新開發項目轉為微服務架構,但有很多基于 SpringMvc 老系統,若都進行系統重構會消耗很大的人力、時間成本。故嘗試在 SpringMvc 系統中通過內建 nacos、feign 的方式讓老系統煥發第二春。

已知

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 發送心跳資訊

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 中注入ConfigService 或 NamingService 執行個體,并使這些執行個體可緩存。 這意味着如果它們的 @NacosProperties 相等,則執行個體将是相同的,無論屬性是來自全局還是自定義的 Nacos 屬性。

spring 內建 openfeign

openfeign 是一種聲明式的web服務用戶端,在 spring cloud 中,僅需建立一個接口并對其進行幾行注釋即可實作調用遠端服務就像調用本地方法一樣,開發者完全感覺不到是在調用遠端方法,更沒有像 HttpClient 那樣相對繁瑣的請求參數封裝與響應解析。但遺憾的是官方隻提供了 Spring Cloud 版本。本文将參照 spring-cloud-openfeign 在 spring 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 {

   ...
}

           

EnableFeignClients 往 spring 的 IOC 容器導入了一個 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);
}

           
BeanDefinitionRegistry 為 spring 中動态注冊 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("\#34;, ".");
            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 中複制以下源碼修改:
  1. 仿照 openfeign 的 FeignClientsConfiguration 添加 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 把相關代碼拷出來,相關代碼如下:
注意由 spring 版本不同導緻的相容問題

修改 FeignConfig#feignContract 如下:

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

           
  1. feign + nacos 內建 這部分實作主要為從 nacos 中擷取已注冊服務清單,feign 根據在 FeignClient 上配置的服務名來調用對應的服務,這部分将在下一節關于內建 ribbon 實作負載均衡中展現。

內建Ribbon

在內建完 nacos + feign 後下一個問題是 nacos 和 feign 都內建好了,如何把他們合在一起使用呢,我們接着看在上節中注冊 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 是用于獲得生産對象的類。

現在來找下 FeignClientFactoryBean 中 getObject 的實作,代碼如下:

@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項目仿 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 內建小節。