Nacos

Dynamic Naming and Configuration Service

一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台

使用

官方文档: https://nacos.io/zh-cn/docs/what-is-nacos.html

核心功能

服务注册:Nacos Client 会通过发送 REST 请求的方式向 Nacos Server 注册自己的服务,提供自身的元数据,比如 ip 地址、端口等信息。Nacos Server 接收到注册请求后,就会把这些元数据信息存储在一个双层的内存 Map 中。

服务心跳:在服务注册后,Nacos Client 会维护一个定时心跳来持续通知 Nacos Server,说明服务一直处于可用状态,防止被剔除。默认 5s 发送一次心跳。

服务同步:Nacos Server 集群之间会互相同步服务实例,用来保证服务信息的一致性。

服务发现:服务消费者(Nacos Client)在调用服务提供者的服务时,会发送一个 REST 请求给 Nacos Server,获取上面注册的服务清单,并且缓存在 Nacos Client 本地,同时会在 Nacos Client 本地开启一个定时任务定时拉取服务端最新的注册表信息更新到本地缓存。

服务健康检查:Nacos Server 会开启一个定时任务用来检查注册服务实例的健康情况,对于超过 15s 没有收到客户端心跳的实例会将它的 healthy 属性置为 false(客户端服务发现时不会发现),如果某个实例超过 30 秒没有收到心跳,直接剔除该实例(被剔除的实例如果恢复发送心跳则会重新注册)。

快速开始

搭建 Nacos-client 服务

1)引入依赖

父 Pom 中支持 spring cloud&spring cloud alibaba, 引入依赖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<dependencyManagement>
<dependencies>
<!--引入springcloud的版本-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Greenwich.SR3</version>
<type>pom</type>
<scope>import</scope>
</dependency>

<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2.1.1.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>

</dependencyManagement>

<repositories>
<repository>
<id>spring</id>
<url>https://maven.aliyun.com/repository/spring</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>

当前项目 pom 中引入依赖

1
2
3
4
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

2)application.properties 中配置

1
2
3
4
5
server.port=8002
#微服务名称
spring.application.name=service-user
#配置 Nacos server 的地址
spring.cloud.nacos.discovery.server-addr=localhost:8848

更多配置:https://github.com/alibaba/spring-cloud-alibaba/wiki/Nacos-discovery

3)启动 springboot 应用,nacos 管理端界面查看是否成功注册

img

服务注册表结构

img

数据模型

Nacos 数据模型 Key 由三元组唯一确定, Namespace 默认是空串,公共命名空间(public),分组默认是 DEFAULT_GROUP。

img

服务领域模型

img

官网地址:https://nacos.io/zh-cn/docs/architecture.html

服务实例数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
[{
"clusterName": "DEFAULT",
"enabled": true,
"ephemeral": true,
"healthy": true,
"instanceHeartBeatInterval": 5000,
"instanceHeartBeatTimeOut": 15000,
"instanceId": "127.0.0.1#8880#DEFAULT#DEFAULT_GROUP@@orderService",
"instanceIdGenerator": "simple",
"ip": "127.0.0.1",
"ipDeleteTimeout": 30000,
"metadata": {},
"port": 8880,
"serviceName": "DEFAULT_GROUP@@orderService",
"weight": 1.0
}, {
"clusterName": "DEFAULT",
"enabled": true,
"ephemeral": true,
"healthy": true,
"instanceHeartBeatInterval": 5000,
"instanceHeartBeatTimeOut": 15000,
"instanceId": "127.0.0.1#8888#DEFAULT#DEFAULT_GROUP@@orderService",
"instanceIdGenerator": "simple",
"ip": "127.0.0.1",
"ipDeleteTimeout": 30000,
"metadata": {},
"port": 8888,
"serviceName": "DEFAULT_GROUP@@orderService",
"weight": 1.0
}]

原理

img

Nacos 基本架构

Naming Service:用来做服务发现的模块

Config Service:用来提供配置项管理、动态更新配置和元数据的功能

image-20220920233149787

Nacos Core 模块:提供一系列的平台基础功能,是支撑 Nacos 上层业务场景的基石

image-20220920233324003

Nacos 还有一个“一致性协议”,用来确保 Nacos 集群中各个节点之间的数据一致性。Nacos 内部支持两种一致性协议,一种是侧重 一致性的 Raft 协议,基于集群中选举出来的 Leader 节点进行数据写入;另一种是针对临 时节点的 Distro 协议,它是一个侧重可用性(或最终一致性)的分布式一致性协议。

Nacos 自动装配原理

Nacos 是怎么在启动阶段自动加载配置项并开启相关功能的呢?

将 Nacos 依赖项添加到项目中,同时也引入了 Nacos 自带的自动装配器,比如下面 这几个被引入的自动装配器就掌管了 Nacos 核心功能的初始化任务。

NacosDiscoveryAutoConfiguration:服务发现功能的自动装配器,它主要做两件事 儿:加载 Nacos 配置项,声明 NacosServiceDiscovery 类用作服务发现;

NacosServiceAutoConfiguration:声明核心服务治理类 NacosServiceManager, 它可以通过 service id、group 等一系列参数获取已注册的服务列表;

NacosServiceRegistryAutoConfiguration:Nacos 服务注册的自动装配器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* @author <a href="mailto:echooy.mxq@gmail.com">echooymxq</a>
**/
@Configuration(proxyBeanMethods = false)
// 当spring.cloud.discovery.enabled=true时才生效
@ConditionalOnDiscoveryEnabled
//当spring.cloud.nacos.discovery.enabled=true时生效
@ConditionalOnNacosDiscoveryEnabled
public class NacosDiscoveryAutoConfiguration {

// 读取Nacos所有配置项并封装到NacosDiscoveryProperties中
@Bean
@ConditionalOnMissingBean
public NacosDiscoveryProperties nacosProperties() {
return new NacosDiscoveryProperties();
}

// 声明服务发现的功能类NacosServiceDiscovery
@Bean
@ConditionalOnMissingBean
public NacosServiceDiscovery nacosServiceDiscovery(
NacosDiscoveryProperties discoveryProperties,
NacosServiceManager nacosServiceManager) {
return new NacosServiceDiscovery(discoveryProperties, nacosServiceManager);
}

}

NacosDiscoveryProperties 类通过 ConfigurationProperties 注解从 spring.cloud.nacos.discovery 路径下获取配置项,Spring 框架会自动将这些配置项解析 到 NacosDiscoveryProperties 类定义的类属性中。这样一来 Nacos 就完成了配置项的加 载,在其它业务流程中,只需要注入 NacosDiscoveryProperties 类就可以读取 Nacos 的 配置参数。

1
2
3
4
5
// 定义了配置项读取的路径
@ConfigurationProperties("spring.cloud.nacos.discovery")
public class NacosDiscoveryProperties {
// 省略类属性
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class NacosServiceDiscovery {
// 封装了Nacos配置项的类
private NacosDiscoveryProperties discoveryProperties;
// 另一个自动装配器声明的核心服务治理类
private NacosServiceManager nacosServiceManager;

// 根据服务名称获取所有已注册服务
public List<ServiceInstance> getInstances(String serviceId) throws NacosException {
String group = discoveryProperties.getGroup();
List<Instance> instances = namingService().selectInstances(serviceId, group,
true);
return hostToServiceInstanceList(instances, serviceId);
}

// 返回所有服务的服务名称
public List<String> getServices() throws NacosException {
String group = discoveryProperties.getGroup();
ListView<String> services = namingService().getServicesOfServer(1,
Integer.MAX_VALUE, group);
return services.getData();
}

...
}

通过 NacosServiceDiscovery 暴露的方法,我们就能够根据 serviceId(注册到 nacos 的 服务名称)查询到可用的服务实例,获取到服务实例列表之后,调用方就可以发起远程服 务调用了。

Nacos 配置项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
cloud:
nacos:
discovery:
# Nacos的服务注册地址,可以配置多个,逗号分隔
server-addr: localhost:8848
# 服务注册到Nacos上的名称,一般不用配置
service: coupon-customer-serv
# nacos客户端向服务端发送心跳的时间间隔,时间单位其实是ms
heart-beat-interval: 5000
# 服务端没有接受到客户端心跳请求就将其设为不健康的时间间隔,默认为15s
# 注:推荐值该值为15s即可,如果有的业务线希望服务下线或者出故障时希望尽快被发现,可
heart-beat-timeout: 20000
# 元数据部分 - 可以自己随便定制
metadata:
mydata: abc
# 客户端在启动时是否读取本地配置项(一个文件)来获取服务列表
# 注:推荐该值为false,若改成true。则客户端会在本地的一个
# 文件中保存服务信息,当下次宕机启动时,会优先读取本地的配置对外提供服务。
naming-load-cache-at-start: false
# 命名空间ID,Nacos通过不同的命名空间来区分不同的环境,进行数据隔离,
namespace: dev
# 创建不同的集群
cluster-name: Cluster-A
# [注意]两个服务如果存在上下游调用关系,必须配置相同的group才能发起访问
group: myGroup
# 向注册中心注册服务,默认为true
# 如果只消费服务,不作为服务提供方,倒是可以设置成false,减少开销
register-enabled: true

image-20220920235023875

Nacos 服务发现底层实现

Nacos Client 通过一种主动轮询的机制从 Nacos Server 获取服务注册信息,包括地址列 表、group 分组、cluster 名称等一系列数据。简单来说,Nacos Client 会开启一个本地 的定时任务,每间隔一段时间,就尝试从 Nacos Server 查询服务注册表,并将最新的注 册信息更新到本地。这种方式也被称之为“Pull”模式,即客户端主动从服务端拉取的模 式。 负责拉取服务的任务是 UpdateTask 类,它实现了 Runnable 接口。Nacos 以开启线程的 方式调用 UpdateTask 类中的 run 方法,触发本地的服务发现查询请求。

UpdateTask 这个类隐藏得非常深,它是 HostReactor的一个内部类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class UpdateTask implements Runnable {

// ...省略部分代码

@Override
public void run() {
long delayTime = DEFAULT_DELAY;

try {
//根据service name获取到当前服务的信息,包括服务器地址列表
ServiceInfo serviceObj = serviceInfoMap.get(ServiceInfo.getKey(serviceName, clusters));

// 如果为空,则重新拉取最新的服务列表
if (serviceObj == null) {
updateService(serviceName, clusters);
return;
}

//如果时间戳<=上次更新的时间,则进行更新操作
if (serviceObj.getLastRefTime() <= lastRefTime) {
updateService(serviceName, clusters);
serviceObj = serviceInfoMap.get(ServiceInfo.getKey(serviceName, clusters));
} else {
// 如果serviceObj的refTime更晚,
// 则表示服务通过主动push机制已被更新,这时我们只进行刷新操作
refreshOnly(serviceName, clusters);
}
//刷新服务的更新时间
lastRefTime = serviceObj.getLastRefTime();

// 如果订阅被取消,则停止更新任务
if (!eventDispatcher.isSubscribed(serviceName, clusters) && !futureMap
.containsKey(ServiceInfo.getKey(serviceName, clusters))) {
// abort the update task
NAMING_LOGGER.info("update task is stopped, service:" + serviceName + ", clusters:" + clusters);
return;
}
//如果没有可供调用的服务列表,则统计失败次数+1
if (CollectionUtils.isEmpty(serviceObj.getHosts())) {
incFailCount();
return;
}
// 设置延迟一段时间后进行查询
delayTime = serviceObj.getCacheMillis();
// 将失败查询次数重置为0
resetFailCount();
} catch (Throwable e) {
incFailCount();
NAMING_LOGGER.warn("[NA] failed to update serviceName: " + serviceName, e);
} finally {
// 设置下一次查询任务的触发时间
executor.schedule(this, Math.min(delayTime << failCount, DEFAULT_DELAY * 60), TimeUnit.MILLISECONDS);
}
}
}

客户端负载均衡

Spring Cloud Loadbalancer 采用了客户端负载均衡技术,每个发起服务调用的客户端都 存有完整的目标服务地址列表,根据配置的负载均衡策略,由客户端自己决定向哪台服务 器发起调用。

image-20220921000035451

客户端负载均衡的优势很明显。

网络开销小:由客户端直接发起点对点的服务调用,没有中间商赚差价;

配置灵活:各个客户端可以根据自己的需要灵活定制负载均衡策略。

客户端负载均衡技术往往需要依赖服务发现技术来获取服务列表

Loadbalancer 工作原理

…todo 2022/09/21

OpenFeign

OpenFeign 组件的前身是 Netflix Feign 项目,它最早是作为 Netflix OSS 项目的一部 分,由 Netflix 公司开发。后来 Feign 项目被贡献给了开源组织,于是才有了我们今天使 用的 Spring Cloud OpenFeign 组件。

OpenFeign 提供了一种声明式的远程调用接口,它可以大幅简化远程调用的编程体验

OpenFeign 使用了一种“动态代理”技术来封装远程服务调用的过程

1
2
3
4
5
@FeignClient(value = "hello-world-serv")
public interface HelloWorldService {
@PostMapping("/sayHello")
String hello(String guestName);
}

服务的名称、接口类型、访问路径已经通过注解做了声明

OpenFeign 通过解析这些注解标签生成一个“动态代理类”,这个代理类会将接口调用转 化为一个远程服务调用的 Request,并发送给目标服务。

OpenFeign 的动态代理

在项目初始化阶段,OpenFeign 会生成一个代理类,对所有通过该接口发起的远程调用进 行动态代理。

image-20220921001114427

上图中的步骤 1 到步骤 3 是在项目启动阶段加载完成的,只有第 4 步“调用远程服务”是 发生在项目的运行阶段。

首先,在项目启动阶段,OpenFeign 框架会发起一个主动的扫包流程,从指定的目录下扫 描并加载所有被 @FeignClient 注解修饰的接口。

然后,OpenFeign 会针对每一个 FeignClient 接口生成一个动态代理对象,即图中的 FeignProxyService,这个代理对象在继承关系上属于 FeignClient 注解所修饰的接口的实 例。

接下来,这个动态代理对象会被添加到 Spring 上下文中,并注入到对应的服务里,也就 是图中的 LocalService 服务。

最后,LocalService 会发起底层方法调用。实际上这个方法调用会被 OpenFeign 生成的代理对象接管,由代理对象发起一个远程服务调用,并将调用的结果返回给 LocalService。

image-20220921001251664

                                                                 **OpenFeign 组件加载过程**

OpenFeign 动态代理类的创建过程

  1. 项目加载:在项目的启动阶段,EnableFeignClients 注解扮演了“启动开关”的角 色,它使用 Spring 框架的 Import 注解导入了 FeignClientsRegistrar 类,开始了 OpenFeign 组件的加载过程。
  2. 扫包:FeignClientsRegistrar 负责 FeignClient 接口的加载,它会在指定的包路径下 扫描所有的 FeignClients 类,并构造 FeignClientFactoryBean 对象来解析 FeignClient 接口。
  3. 解析 FeignClient 注解:FeignClientFactoryBean 有两个重要的功能,一个是解析 FeignClient 接口中的请求路径和降级函数的配置信息;另一个是触发动态代理的构造过程。其中,动态代理构造是由更下一层的 ReflectiveFeign 完成的。
  4. 构建动态代理对象:ReflectiveFeign 包含了 OpenFeign 动态代理的核心逻辑,它主 要负责创建出 FeignClient 接口的动态代理对象。ReflectiveFeign 在这个过程中有两个 重要任务,一个是解析 FeignClient 接口上各个方法级别的注解,将其中的远程接口 URL、接口类型(GET、POST 等)、各个请求参数等封装成元数据,并为每一个方法 生成一个对应的 MethodHandler 类作为方法级别的代理;另一个重要任务是将这些 MethodHandler 方法代理做进一步封装,通过 Java 标准的动态代理协议,构建一个实 现了 InvocationHandler 接口的动态代理对象,并将这个动态代理对象绑定到FeignClient 接口上。这样一来,所有发生在 FeignClient 接口上的调用,最终都会由它 背后的动态代理对象来承接。

MethodHandler 的构建过程涉及到了复杂的元数据解析,OpenFeign 组件将 FeignClient 接口上的各种注解封装成元数据,并利用这些元数据把一个方法调用“翻译”成一个远程调用的 Request 请求。

“元数据的解析”是如何完成的呢?它依赖于 OpenFeign 组件中的 Contract 协议解析功能。Contract 是 OpenFeign 组件中定义的顶层抽象接口,它有一系 列的具体实现,其中和我们实战项目有关的是 SpringMvcContract 这个类,从这个类的名 字中我们就能看出来,它是专门用来解析 Spring MVC 标签的。

SpringMvcContract 的继承结构是 SpringMvcContract->BaseContract->Contract。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 解析FeignClient接口方法级别上的RequestMapping注解
@Override
protected void processAnnotationOnMethod(MethodMetadata data,
Annotation methodAnnotation, Method method) {
// 省略部分代码...

// 如果方法上没有使用RequestMapping注解,则不进行解析
// 其实GetMapping、PostMapping等注解都属于RequestMapping注解
if (!RequestMapping.class.isInstance(methodAnnotation) && !methodAnnotation
.annotationType().isAnnotationPresent(RequestMapping.class)) {
return;
}
// 获取RequestMapping注解实例
RequestMapping methodMapping = findMergedAnnotation(method, RequestMapping.class);
// HTTP Method
RequestMethod[] methods = methodMapping.method();
// 如果没有定义methods属性则默认当前方法是个GET方法
if (methods.length == 0) {
methods = new RequestMethod[] { RequestMethod.GET };
}
checkOne(method, methods, "method");
data.template().method(Request.HttpMethod.valueOf(methods[0].name()));

// 解析Path属性,即方法上写明的请求路径
checkAtMostOne(method, methodMapping.value(), "value");
if (methodMapping.value().length > 0) {
String pathValue = emptyToNull(methodMapping.value()[0]);
if (pathValue != null) {
pathValue = resolve(pathValue);
// Append path from @RequestMapping if value is present on method
if (!pathValue.startsWith("/") && !data.template().path().endsWith("/")) {
pathValue = "/" + pathValue;
}
data.template().uri(pathValue, true);
}
}

// produces
parseProduces(data, method, methodMapping);

// consumes
parseConsumes(data, method, methodMapping);

// headers
parseHeaders(data, method, methodMapping);

data.indexToExpander(new LinkedHashMap<>());
}

通过上面的方法,我们可以看到,OpenFeign 对 RequestMappings 注解的各个属性都做 了解析。

Debug

在 OpenFeign 组件的 FeignClientsRegistrar 中打上一个断点,这是 OpenFeign 初始化的起点

日志信息打印

1
2
3
4
logging:
level:
com.geekbang.coupon.customer.feign.TemplateService: debug
com.geekbang.coupon.customer.feign.CalculationService: debug

还需要在应用的上下文中使用代码的方式声明 Feign 组件的日志级别

1
2
3
4
5
//在 Configuration 配置类中
@Bean
Logger.Level feignLogger() {
return Logger.Level.FULL;
}

OpenFeign 超时判定

1
2
3
4
5
6
7
8
9
10
11
12
13
feign:
client:
config:
# 全局超时配置
default:
# 网络连接阶段1秒超时
connectTimeout: 1000
# 服务请求响应阶段5秒超时
readTimeout: 5000
# 针对某个特定服务的超时配置
coupon-template-serv:
connectTimeout: 1000
readTimeout: 2000

OpenFeign 降级

降级逻辑是在远程服务调用发生超时或者异常(比如 400、500 Error Code)的时候,自 动执行的一段业务逻辑。可以根据具体的业务需要编写降级逻辑,比如执行一段兜底逻辑将服务请求从失败状态中恢复,或者发送一个失败通知到相关团队提醒它们来线上排查 问题。

OpenFeign 实现 Client 端的服务降级

OpenFeign 对服务降级的支持是借助 Hystrix 组件实现的

OpenFeign 支持两种不同的方式来指定降级逻辑,一种是定义 fallback 类,另一种是定义 fallback 工厂

通过 fallback 类实现降级是最为简单的一种途径

如果你想要在降级方法中获取到异常的具体原因,那么你就要借助 fallback 工厂的方式来 指定降级逻辑了

自定义的 fallback 工厂需要实现 FallbackFactory 接口

image-20220921003602982