SpringMvc專案整合nacos、openfeign、Ribbon,仿 springcloud openfeign 實現微服務下介面呼叫
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
新增依賴
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中注入
ConfigService或
NamingService例項,並使這些例項**可快取**。 這意味著如果它們的
@NacosProperties相等,則例項將是相同的,無論屬性是來自全域性還是自定義的
Nacos` 屬性。參考:Nacos Spring
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 原始碼分析
- 從開啟
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);
}
BeanDefinitionRegistry
為spring
中動態註冊beanDefinition
的介面。
registerDefaultConfiguration
用來註冊 EnableFeignClients
中提供的自定義配置類中的 Bean
,我們主要來看 registerFeignClients
:
java
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 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 介面發現與註冊
- 從
openfeign
中複製以下原始碼修改:
- 仿照
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();
}
2. feign
+ nacos
整合
這部分實現主要為從 nacos
中獲取已註冊服務列表,feign
根據在 FeignClient
上配置的服務名來呼叫對應的服務,這部分將在下一節關於整合 ribbon
實現負載均衡中體現。
整合Ribbon
在整合完 nacos + feign
後下一個問題是 nacos
和 feign
都整合好了,如何把他們合在一起使用呢,我們接著看在上節中註冊 feignClient
是說到的 FeignClientFactoryBean
:
java
class FeignClientFactoryBean
implements FactoryBean<Object>, InitializingBean, ApplicationContextAware {
...
}
其實現了 FactoryBean
介面,我們知道如果要使用 Bean
工廠,可以手動實現一個 FactoryBean
的類,改介面有三個方法如下:
``` java
public interface FactoryBean
@Nullable
T getObject() throws Exception;
@Nullable
Class<?> getObjectType();
default boolean isSingleton() {
return true;
}
} ```
其中
isSingleton
是用來判斷生產的bean
是否是單例,有預設實現,我們不需要手動實現。getObject
方法是獲得生產出來的bean
物件,getObjectType
是用於獲得生產物件的類。
現在來找下 FeignClientFactoryBean
中 getObject
的實現,程式碼如下:
``` java
@Override
public Object getObject() throws Exception {
return getTarget();
}
/*
* @param
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
throw new IllegalStateException(
"No Feign Client for loadBalancing defined. Did you forget to include spring-cloud-starter-netflix-ribbon?");
}
``
該方法接收一個
feign builder和一個
feign context`,打個斷點除錯下這段程式碼:
可以看到
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``
整合完
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
感謝以上大佬的分享。