SpringMvc專案整合nacos、openfeign、Ribbon,仿 springcloud openfeign 實現微服務下介面呼叫

語言: CN / TW / HK

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

新增依賴 xml <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 的服務發現功能: ```java @Configuration @EnableNacosDiscovery(globalProperties = @NacosProperties(serverAddr = "127.0.0.1:8848")) public class NacosConfiguration {

} `` -springmvc整合nacos` 可參考nacos文件spring部分。

注意:按照 nacos 官方整合到 spring 的例子配置後會發現 nacos 管理端可以檢視到服務,但是一會就消失了,懷疑是 spring 服務未定時傳送心跳連結導致。 檢視nacos原始碼中傳送心跳連結部分:

```java

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 配置類修改為: ```java /* * @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 {

... } `EnableFeignClients` 往 `spring` 的 `IOC` 容器匯入了一個 `FeignClientsRegistrar` 例項。 class FeignClientsRegistrar implements ImportBeanDefinitionRegistrar, ResourceLoaderAware, EnvironmentAware {

} ```

FeignClientsRegistrar 實現了 ImportBeanDefinitionRegistrar 介面,使用 @Import,如果括號中匯入的類是 ImportBeanDefinitionRegistrar 的實現類,則會呼叫介面方法 registerBeanDefinitions,將其中要註冊的類註冊成 bean

java @Override public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) { // 註冊預設配置 registerDefaultConfiguration(metadata, registry); // 註冊 feignClients registerFeignClients(metadata, registry); }

BeanDefinitionRegistryspring 中動態註冊 beanDefinition 的介面。

registerDefaultConfiguration 用來註冊 EnableFeignClients 中提供的自定義配置類中的 Bean,我們主要來看 registerFeignClientsjava 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 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 中複製以下原始碼修改:

image.png

  1. 仿照 openfeignFeignClientsConfiguration 新增 FeignConfig 配置類 ``` /**
  2. @author: kkfan
  3. @create: 2021-07-08 15:54:44
  4. @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 把相關程式碼拷出來,相關程式碼如下:

image.png

注意由 spring 版本不同導致的相容問題

修改 FeignConfig#feignContract 如下: @Bean public Contract feignContract() { return new SpringMvcContract(); } 2. feign + nacos 整合 這部分實現主要為從 nacos 中獲取已註冊服務列表,feign 根據在 FeignClient 上配置的服務名來呼叫對應的服務,這部分將在下一節關於整合 ribbon 實現負載均衡中體現。

整合Ribbon

在整合完 nacos + feign 後下一個問題是 nacosfeign 都整合好了,如何把他們合在一起使用呢,我們接著看在上節中註冊 feignClient 是說到的 FeignClientFactoryBeanjava class FeignClientFactoryBean implements FactoryBean<Object>, InitializingBean, ApplicationContextAware { ... } 其實現了 FactoryBean 介面,我們知道如果要使用 Bean 工廠,可以手動實現一個 FactoryBean 的類,改介面有三個方法如下: ``` java public interface FactoryBean { String OBJECT_TYPE_ATTRIBUTE = "factoryBeanObjectType";

@Nullable
T getObject() throws Exception;

@Nullable
Class<?> getObjectType();

default boolean isSingleton() {
    return true;
}

} ```

其中 isSingleton 是用來判斷生產的 bean 是否是單例,有預設實現,我們不需要手動實現。getObject 方法是獲得生產出來的 bean 物件,getObjectType 是用於獲得生產物件的類。

現在來找下 FeignClientFactoryBeangetObject 的實現,程式碼如下: ``` java @Override public Object getObject() throws Exception { return getTarget(); }

/* * @param the target type of the Feign client * @return a {@link Feign} client created with the specified data and the context * information / 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` 方法,從字面意思上看負載均衡,應該就是想要的,接著往下看: java protected T loadBalance(Feign.Builder builder, FeignContext context, HardCodedTarget 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`,打個斷點除錯下這段程式碼:

image.png 可以看到 getOption 從上下文中獲取了一個 Client 例項 LoadBalancerFeignClient 後新增到 feign builder 中,現在問題就解決了,在 spring 整合 openfeign 一節中有建立 feignBuilder,在其中加入ribbon client 即可,程式碼如下: ``` java @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` 負載均衡策略如下: java / * Ribbon負載均衡策略實現 * 使用ZoneAvoidancePredicate和AvailabilityPredicate來判斷是否選擇某個server,前一個判斷判定一個zone的執行效能是否可用, * 剔除不可用的zone(的所有server),AvailabilityPredicate用於過濾掉連線數過多的Server。 * @return */ private IRule zoneAvoidanceRule() { return new ZoneAvoidanceRule(); } 可用服務列表根據服務名稱從nacos中讀取: java / * 從nacos讀取服務, 封裝節點 * @param name * @return */ private List getByServerName(String name) { List servers = new ArrayList<>(); try { List 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` 整合小節。

測試

略,以上為本人測試通過後記錄。

因本人能力有限,文中可能有很多不足之處,故謝絕轉載,謝謝。

參考

https://www.cnblogs.com/dalianpai/p/15428050.html https://blog.csdn.net/jll126/article/details/108491955 https://blog.csdn.net/taiyangdao/article/details/81359394 https://my.oschina.net/redking/blog/2877147 https://blog.csdn.net/menggudaoke/article/details/83383977 https://github.com/spring-cloud/spring-cloud-netflix/issues/1253 https://cofcool.github.io/tech/2019/09/18/spring-mvc-openfeign

感謝以上大佬的分享。