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
添加依赖
<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 {
}
springmvc
集成nacos
可参考nacos文档spring部分。
注意:按照
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
中注入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
。
@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("\$", ".");
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 接口发现与注册
- 从
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
的集成,但还存在以下问题:
FeignClient
注解类中的SpringMvc
的注解不支持;- 未和
nacos
集成使用,只能在FeignClient
中指明调用地址。
下面来解决上面两个问题:
- 支持
SpringMvc
注解 参考openfeign
中的SpringMvcContract
把相关代码拷出来,相关代码如下:
注意由
spring
版本不同导致的兼容问题
修改 FeignConfig#feignContract
如下:
@Bean
public Contract feignContract() {
return new SpringMvcContract();
}
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
,打个断点调试下这段代码:
可以看到
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…
感谢以上大佬的分享。
转载自:https://juejin.cn/post/7189881963925209146