万字长文 | Spring Cloud Alibaba组件之Nacos实战及Nacos客户端服务注册源码解析

滴滴滴,上车了!

本次旅途,你将获取到如下知识:

  • Nacos在微服务架构中的作用
  • Nacos在Linux下的安装与使用
  • 搭建真实项目环境,实现服务注册与发现
  • 真实项目环境下实现Nacos的配置管理
  • Nacos集群配置与集群数据持久化到MySQL
  • 使用Nginx负载均衡访问Nacos集群
  • Nacos客户端服务注册源码分析

先赞后看,养成习惯。

举手之劳,赞有余香。

Nacos在微服务架构中的作用

服务发现

注册中心是微服务架构中不可缺少的一环,用作 服务注册发现

为何使用注册中心?

假设有这样一个场景,我们乘坐公车需要确定自己的座位在哪里,才能入座,否则有可能那是别人的座位,等别人来做的时候把我撵起来那就尴尬了。正常的情况应该是有个售票员给我发个带有座位编号的票,然后我去对号入座,这样就可以快速找到自己的座位且没有被撵走的风险。

这里,售票员 其实就是 提供注册和发现 的一个中间联络员。座位的信息已经登记到售票员那里了,乘客来乘车,只需要找售票员就能快速找到自己的座位。

找车上座位模型

微服务架构体系中,各个微服务组件相互独立,但最终还要组合为一个整体作为一个软件系统服务于最终客户,在整个大系统内部,各个服务之间需要彼此通讯,彼此调用方法。

微服务架构内部发起通讯调用方法的一方就是 服务消费者,提供远程方法调用的服务称为 服务提供者

而为了提高系统性能,一般会提供多个服务器作为 服务提供者,此时 服务消费者 找到 服务提供者 的过程,就类似于乘客上车找座位的过程。

Nacos微服务注册中心

因此,在微服务架构中都会引入 注册中心 ,这样就能使服务的消费者快速的找到它需要的服务提供者。注册中心实现了服务提供和服务消费的快速撮合功能。

Nacos 提供了一组简单易用的特性集,可以快速实现动态服务发现。

配置中心

除了服务发现,在服务繁多的微服务架构体系中,配置 的集中化管理也非常重要,因为服务数量有很多,每次修改一个配置有可能需要跟进多个服务对其进行同步修改,然后再重启这些项目,那就麻烦了。配置中心 就用来完成配置的统一管理,修改一处,实时生效。

可以结合我之前的一篇介绍Apollo配置中心的文章一起食用:分布式配置中心之Apollo实战

Nacos 不仅能做微服务的注册中心,同时它还支持做配置中心。

Nacos安装使用

NacosSpring Cloud Alibaba 的组件之一,支持服务的注册发现,支持分布式系统的外部化配置和配置的自动刷新。

现在该把 Nacos 环境支棱起来了。

本文 Nacos 安装环境:

  • CentOS 7.6 2C 4G
  • JDK 1.8

版本选择

下载当前官方的推荐版本:2.0.3 ,下载地址:

官方稳定版本下载地址

解压并启动

将下载下来的 nacos-server-2.0.3.tar.gz 上传到服务器中(服务器IP地址:192.168.242.129,记住这个IP地址,后面将和MySQL和nginx所在服务器区别),解压:

# 解压
tar -zxvf nacos-server-2.0.3.tar.gz
# 启动
cd nacos/bin
sh startup.sh -m standalone

启动命令中 standalone 代表着单机模式运行,非集群模式。

验证Nacos是否启动成功

可先在服务器端看nacos服务是否启动成功:

[root@localhost ~]# jps
9763 nacos-server.jar
13720 Jps
[root@localhost ~]# ps -ef|grep nacos

Nacos启动成功

Nacos服务的默认端口是 8848 ,浏览器端打开如下网址验证:

http://192.168.242.129:8848/nacos

TIP: IP地址换成自己实际环境的IP地址,并注意防火墙、端口开放。

Nacos登录页

默认用户名密码:nacos/nacos

登录后可以看到有服务管理、配置管理等:

这样,一个Nacos服务就配置好了。

项目实战演示

本项目 SpringCloudAlibabaTest 源代码仓库:github.com/xblzer/Spri…

因为Nacos既可以作为服务发现注册中心,也可以是配置中心,所以我这里也分两部分进行操作。

demo结构用 Maven父子工程 ,Maven父工程导入 Spring BootSpring CloudSpring Cloud Alibaba 基础依赖,各个子工程作为module依赖父工程。

创建Maven父工程

IDE:IntelliJ IDEA

创建一个普通的Maven工程,并删除IDE自动生成的文件夹和文件,只保留 pom.xml 文件:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.xblzer</groupId>
    <artifactId>SpringCloudAlibabaTest</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>pom</packaging>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

    <!-- 作版本仲裁 -->
    <dependencyManagement>
        <dependencies>
            <!-- Spring Boot -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>2.6.3</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <!-- Spring Cloud -->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>2021.0.1</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <!-- Spring Cloud Alibaba -->
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-alibaba-dependencies</artifactId>
                <version>2021.0.1.0</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
</project>

父工程创建OK。

本文所选版本是:

  • Spring Boot:2.6.3
  • Spring Cloud:2021.0.1
  • Spring Cloud Alibaba:2021.0.1.0

Tip:本文使用的是Spring Cloud Alibaba 2021.0.1.0,该版本对应的Spring Cloud版本为2021.0.1。从 2021.0.1.0 开始,Spring Cloud Alibaba 版本将会对应 Spring Cloud 版本, 前三位为 Spring Cloud 版本,最后一位为扩展版本。

Spring Cloud 2021.0.1 新版本使用 Spring Cloud Loadbalancer 做负载均衡,没有默认集成 Ribbon 了,在进行服务消费者开发的项目中需要引入 Loadbalancer 依赖,这一点需要注意一下。

Part Ⅰ:服务注册与发现

服务提供者项目

和创建普通Spring Boot项目一样,创建完成后,删除无用的文件,保留src和pom.xml。

因为是子工程,在pom中添加其父工程依赖:

<parent>
    <groupId>com.xblzer</groupId>
    <artifactId>SpringCloudAlibabaTest</artifactId>
    <version>1.0-SNAPSHOT</version>
    <relativePath/>
</parent>

依赖中引入 spring-cloud-starter-alibaba-nacos-discovery

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

然后在父工程的pom中添加子模块:

<modules>
    <module>cloud-nacos-provider</module>
</modules>

Nacos服务提供者配置文件 application.yml

server:
  port: 8080

spring:
  application:
    name: cloud-nacos-provider

  cloud:
    nacos:
      discovery:
        server-addr: 192.168.242.129:8848

management:
  endpoint:
    web:
      exposure:
        include: "*"

主启动类上加 @EnableDiscoveryClient 注解。

然后启动 cloud-nacos-provider 项目,看Nacos后台是否注册上该服务了:

现在编写一个对外提供的接口 /test-port ,访问该接口时,返回项目的端口。写一个 Controller 就行了:

package com.xblzer.cloudnacosprovider.controller;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author 行百里者
 * @date 2022-06-30 18:29
 */
@RestController
public class ProviderController {

    @Value("${server.port}")
    private String serverPort;

    @GetMapping("/test-port")
    public String getServerPort() {
        return "Nacos Provider port:" + serverPort;
    }
}

既然是对外提供服务,一般我们会多准备几个服务提供者的服务器,已提高系统效率和备份,这里再启动一个 8081 端口的Provider服务。

启动两个Provider服务后,可以看到Nacos后台服务列表注册成功:

服务消费者项目

创建子module的过程和前面一样,主要是配置文件和pom有些区别。

配置文件 application.yml

server:
  port: 9080

spring:
  application:
    name: cloud-nacos-consumer

  cloud:
    nacos:
      discovery:
        server-addr: 192.168.242.129:8848

# 消费者要访问的服务提供者-这些服务提供者已注册到nacos
service-url:
  nacos-provider-service: http://cloud-nacos-provider

前文提到过,既然是服务消费者,肯定需要去调用服务提供者提供的接口,服务提供者是多台服务器的,那么我应该去调用哪台服务(这里假设不同的端口服务部署在不同的服务器上)的接口呢?

使用 Spring Cloud Loadbalancer 就可以做负载均衡了,需要引入 Loadbalancer 依赖:

<!-- 2021.0.1版本移除了netflix ribbon 使用LoadBalancer 必须引入LoadBalancer的依赖 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>

引入依赖后,我们只需要在注入 RestTemplate 的时候加上 @LoadBalanced 注解即可。

RestTemplate 是 Spring 提供的用于访问 Rest 服务的客户端,它提供了多种边界访问远程 Http 服务的方法,能够大大提高客户端的编写效率。

@Bean
@LoadBalanced
public RestTemplate restTemplate() {
    return new RestTemplate();
}

编写调用接口

在接口中调用服务提供者的接口 /test-port

@RestController
public class ConsumerController {

    @Resource
    private RestTemplate restTemplate;

    @Value("${service-url.nacos-provider-service}")
    private String serviceUrl;

    @GetMapping("/comsume")
    public String consume() {
        return restTemplate.getForObject(serviceUrl + "/test-port", String.class);
    }
}

启动并验证是否注册到Nacos中:

访问 http://localhost:9080/consume

多次调用该接口,返回的信息在 8080 与 8081 之间切换,可见实现了负载均衡。

Part Ⅱ:配置中心

Nacos不仅仅可以作为注册中心来使用,同时它支持作为配置中心,我们来看一下怎么用。

同样创建module,引入nacos config依赖:

<!-- 引入Nacos config -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>

将该子模块添加到父工程:

<modules>
    <module>cloud-nacos-provider</module>
    <module>cloud-nacos-consumer</module>
    <module>cloud-nacos-config</module>
</modules>

关于配置文件,需要注意的是,spring-cloud-starter-alibaba-nacos-config 模块移除了 spring-cloud-starter-bootstrap 依赖,如果想以旧版的方式使用,需要手动加上该依赖。

旧版使用方式:

有两个配置文件,一个 application.yml ,一个 bootstrap.yml ,在项目初始化时,要保证先从配置中心进行配置拉取,拉取配置之后,才能保证项目的正常启动。

bootstrap.yml文件内容:

# nacos配置
server:
  port: 7071

spring:
  application:
    name: nacos-config-client
  cloud:
    nacos:
      discovery:
        # Nacos服务注册中心地址
        server-addr: 192.168.242.129:8848 
      config:
      	# Nacos作为配置中心地址
        server-addr: 192.168.242.129:8848 
        # 指定yaml格式的配置
        file-extension: yaml 

这里bootstrap.yml配置的内容起到两个作用:

  • 让7071这个配置服务注册到Nacos中
  • 去Nacos中读取指定后缀为yaml的配置文件

现在推荐使用 spring.config.import 方式引入配置,以上述 bootstrap.yml 的配置为例,spring.config.import 引入方式如下:

配置文件 application.yml

server:
  port: 7071

spring:
  application:
    name: cloud-nacos-config

  cloud:
    nacos:
      config:
        group: DEFAULT_GROUP
        server-addr: 192.168.242.129:8848
  config:
    import:
      - nacos:test.yml

Tip: 配置文件的写法一定要注意,spring.config.import 下面的配置 nacos:test.yml 中间一定不要留空格,否则启动不成功。

在Nacos,需要在DEFAULT_GROUP下创建一个 test.yml 文件,这个文件名一定要和 spring.config.import 配置下的 nacos:test.yml 的yml文件名一致。

项目启动成功,访问 http://localhost:7071/info :

在Nacos配置管理里面,动态修改 config.info 的值为 I am a config info, v2 再次访问接口,将返回新值:

PS:关于旧版本Spring Boot中Nacos配置中心的配置规则

首先必须配置 spring.application.name ,是因为它是构成 Nacos 配置管理 dataId字段的一部分。

在 Nacos Spring Cloud 中,dataId 的完整格式如下:

${prefix}-${spring.profiles.active}.${file-extension}
  • prefix 默认为 spring.application.name 的值,也可以通过配置项 spring.cloud.nacos.config.prefix来配置。
  • spring.profiles.active 即为当前环境对应的 profile。 注意:当 spring.profiles.active 为空时,对应的连接符 - 也将不存在,dataId 的拼接格式变成 ${prefix}.${file-extension}
  • file-exetension 为配置内容的数据格式,可以通过配置项 spring.cloud.nacos.config.file-extension 来配置。目前只支持 propertiesyaml 类型。

Nacos集群

生产环境中,Nacos的配置一般是集群模式部署,来满足高可用。

现在我们来想一个问题,前面我们配置的test.yml在一台机器的nacos上,也就是这个配置在这台服务器(192.168.242.129)的nacos内部的数据库里存储着,一旦我们改成集群部署,这些数据怎么保证一致性呢?

看一下Nacos集群架构图先:

Nacos集群架构

前面我们操作的都是Nacos单节点,Nacos默认使用嵌入式数据库实现数据的存储,所以,如果启动多个默认配置下的Nacos节点,数据储存存在一致性问题

为了解决这个问题,Nacos采用了集中存储方式来支持集群化部署,目前仅支持MySQL的存储。

下面我就根据这个集群架构来部署一套Nacos集群。该集群模式下,需要有nginx对Nacos做负载均衡,MySQL做存储。

Nacos的数据持久化

Nacos默认的内部存储数据的数据库是内置的derby数据库,我们搭建集群环境的话,为了保证数据的一致性,将不再继续使用默认的derby,通过修改配置,将数据持久化到MySQL数据库。

Nacos默认内部存储derby

Nacos默认Derby数据库切换到外部MySQL数据库方法

前面安装的Nacos所在的服务器IP地址为 192.168.242.129 ,MySQL所在服务器在 192.168.242.112

第一步: 将Nacos安装目录 conf 下的 nacos-mysql.sql 文件上传到MySQL所在的服务器 192.168.242.112 (以下简称112)中;

# 上传sql脚本文件到MySQL所在的112服务
scp nacos-mysql.sql [email protected]:/usr/local/sql-scripts/

使用SCP命令上传sql脚本到MySQL服务器

第二步: 在MySQL服务器上,创建 nacos 数据库,导入 nacos-mysql.sql 脚本;

mysql> create database nacos;
Query OK, 1 row affected (0.03 sec)

mysql> use nacos;

mysql> source /usr/local/sql-scripts/nacos-mysql.sql;

mysql> show tables;
+----------------------+
| Tables_in_nacos      |
+----------------------+
| config_info          |
| config_info_aggr     |
| config_info_beta     |
| config_info_tag      |
| config_tags_relation |
| group_capacity       |
| his_config_info      |
| permissions          |
| roles                |
| tenant_capacity      |
| tenant_info          |
| users                |
+----------------------+
12 rows in set (0.00 sec)

第三步: 修改 conf/application.properties 文件,将其中MySQL配置的部分修改为如下内容:

#*************** Config Module Related Configurations ***************#
### If use MySQL as datasource:
spring.datasource.platform=mysql

### Count of DB:
db.num=1

### Connect URL of DB:
db.url.0=jdbc:mysql://192.168.242.112:3306/nacos?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC
db.user.0=zhangsan
db.password.0=Fawai@kuangtu6

通过以上操作,此时仍以单机模式重启nacos,

cd /usr/local/nacos/bin
sh shutdown.sh
sh startup.sh -m standalone

查看启动日志,启动日志输出文件: /usr/local/nacos/logs/start.out

nacos启动成功日志

此时,访问nacos后台,发现之前我们的配置文件 test.yml 消失了,这是因为我们切换了默认的derby存储,换成了外部存储MySQL。

重新创建一个 test.yml

这条记录在配置的MySQL数据库中可以查到:

mysql> select * from config_info\G
*************************** 1. row ***************************
          id: 1
     data_id: test.yml
    group_id: DEFAULT_GROUP
     content: config:
    info: this is new version!
         md5: 57609a39c0477b74a7e5315c2acd062b
  gmt_create: 2022-07-07 07:22:41
gmt_modified: 2022-07-07 07:22:41
    src_user: NULL
      src_ip: 192.168.242.1
    app_name: 
   tenant_id: 
      c_desc: NULL
       c_use: NULL
      effect: NULL
        type: yaml
    c_schema: NULL
1 row in set (0.00 sec)

这样就实现了Nacos数据持久化到外部存储MySQL中。

集群搭建-主机管理

Nacos集群中各个环节(SLB、Nacos、MySQL)所需要的主机信息分配如下:

序号 IP地址(简称) 部署服务
1 192.168.242.112(112) Nginx
2 192.168.242.112(112) MySQL
3 192.168.242.129(129) Nacos
4 192.168.242.130(130) Nacos
5 192.168.242.131(131) Nacos

为了方便,Nginx和MySQL就不做高可用了,Nginx和MySQL部署在 192.168.242.112 上,另外三台主机部署Nacos。

也可以在一台服务器上部署三个Nacos服务,通过端口来区分。

注意: 如果你是在一台机器上用三个端口的服务来搭建nacos集群,在修改端口的时候一定要有一定的偏移量(比如三个nacos分别设置成8848/8868/8888),不要设置成8848/8849/8850这样, 因为Nacos2.0增加了9848,9849端口来进行GRPC通信,这两个端口是通过8848+1000以及8848+1001这种偏移量方式计算出来的,如果我们将集群中的第二个端口设置成8849,那么8849+1000就和第一个的8848+1001端口重合了!

所以我们在设置端口号的时候注意要避开,不要占用端口。

我这里为了模拟实际场景,我整了三台部署Nacos的虚拟机,由于在三台机器上,我可以均以默认的8848端口部署。

集群配置

在130/131这两台虚拟机中,将 conf/application.properties 中MySQL的部分修改成一致的,然后分别修改三台机器上的 nacos/cluster.conf 文件。

先拷贝一份 cluster.conf :

cp cluster.conf.example cluster.conf

然后修改 cluster.conf 内容为:

# ip:port
192.168.242.129:8848
192.168.242.130:8848
192.168.242.131:8848

三台Nacos服务均如此修改。

这样,一个Nacos集群就支棱起来了,启动nacos集群也相当的简单,直接执行 bin/starup.sh 就可以了,nacos默认的启动方式就是集群方式启动。

以集群模式自动Nacos

这时,访问 http://192.168.242.129:8848/nacos、http://192.168.242.130:8848/nacos、http://192.168.242.131:8848/nacos 均能看到nacos后台,集群节点:

集群节点

Nginx做Nacos集群的SLB(负载均衡)

访问Nacos集群,需要对外提供一个统一的ip地址,使用nginx做集群的负载均衡。

Nginx安装

这里选择 tengine (阿里版的nginx),安装步骤:

1. 上传 tengine-2.3.3.tar.gz 文件到 /usr/local/warehouse

2. cd /usr/local/warehouse

3. tar -zxvf tengine-2.3.3.tar.gz

4. cd tengine-2.3.3/

5. ./configure --with-stream --prefix=/usr/local/nginx

6. make && make install

安装过程可能出现的错及解决办法

# 错误为:./configure: error: the HTTP rewrite module requires the PCRE library.

# 安装pcre-devel解决问题
yum -y install pcre-devel

#还有可能出现:./configure: error: the HTTP cache module requires md5 functions from OpenSSL library
# 解决办法:
yum -y install openssl openssl-devel
配置nacos集群代理

这里,只需要对nginx做如下配置即可:

# 编辑nginx.conf文件
vi /usr/local/nginx/conf/nginx.conf

nacos代理配置:

stream {
      upstream nacos {
        server 192.168.242.129:8848;
        server 192.168.242.130:8848;
        server 192.168.242.131:8848;
      }


     server {
        listen  81;
        proxy_pass nacos;
     }
}

启动nginx:

/usr/local/nginx/sbin/nginx

现在,直接访问 http://192.168.242.112:81/nacos 地址就可以访问Nacos集群了:

image-20220707165120035

代码中单机Nacos切换成Nacos集群模式

在前文例子 SpringCloudAlibabaTest 项目中,用到的Nacos均是单机模式下的Nacos,要切换到集群模式,只需要将IP地址换成Nginx代理的ip地址 192.168.242.112:81 即可。

比如,将 cloud-nacos-config 项目配置修改如下,并启动项目:

spring:
  application:
    name: cloud-nacos-config

  cloud:
    nacos:
      config:
        group: DEFAULT_GROUP
        server-addr: 192.168.242.112:81
  config:
    import:
      - nacos:test.yml

访问接口:

能够得到集群中配置的值!

Nacos客户端服务注册源码分析

到这里,我们已经对Nacos的服务注册发现、配置管理等功能进行了实际操作,也体验到了它的强大。

我们可以跟着源码总结一下其中的一些核心点,最后能够跟着源码来做出核心流程图,当我们对核心功能的实现了解其源码后,就可能会借鉴到实际工作项目中,提升我们的编程技能和编程思想。

那么这些Nacos有哪些核心功能呢?他们又是怎么实现的?

前面搭建了真实的微服务项目环境,体验了Nacos作为服务注册、服务发现以及配置中心的功能,这些功能里面包含了一下核心知识点:

  • 服务注册: Nacos Client 会通过发送REST请求的方式向 Nacos Server 注册自己的服务,提供自身的元数据,比如ip地址、端口等信息。Nacos Server接收到注册请求后,就会把这些元数据信息存储在一个 双层的内存Map 中。
  • 服务发现: 服务消费者(Nacos Client)在调用服务提供者的服务时,会发送一个REST请求给Nacos Server,获取上面注册的服务清单,并且缓存在Nacos Client本地,同时会在Nacos Client本地开启一个定时任务定时拉取服务端最新的注册表信息更新到本地缓存
  • 服务心跳: 在服务注册后,Nacos Client 会维护一个 定时心跳 来持续通知 Nacos Server ,说明服务一直处于可用状态,防止被剔除。 默认5s发送一次心跳。
  • 服务健康检查: Nacos Server 会开启一个 定时任务 用来检查注册服务实例的健康情况,对于 超过15s没有收到客户端心跳的实例会将它的healthy属性置为false (客户端服务发现时不会发现),如果某个 实例超过30秒没有收到心跳,直接剔除该实例 (被剔除的实例如果恢复发送心跳则会重新注册)。
  • 服务同步: Nacos Server集群 之间会互相同步服务实例,用来保证服务信息的 一致性

Nacos源码环境搭建

因为前面我们的Nacos版本选择的是 2.0.3 ,所以下载源码的时候去下载对应版本的源码:

源码下载

如果直接拉取 github.com/alibaba/nac… ,下载的源码是最新版2.1.0。

下载下来导入到Idea中,项目结构为:

Nacos源码结构

启动后台管理 nacos-console 模块的启动类 Nacos.java ,如果直接启动报如下错误:

原因是 Nacos 2.0 版本使用的是protocol buffer compiler编译,这里我们下载下来后使用Maven compile ,重新编译一下就行了。

启动的时候还需要加个参数,以单机模式启动:

-Dnacos.standalone=true

如果不加这个参数,默认以集群方式启动,这种方式启动需要修改 application.properties 中关于数据库MySQL部分的配置(保证集群数据一致性),否则启动会报错 Unable to start embedded Tomcat

看源码,只需要单机模式启动就行了。在Idea中添加启动参数如下:

配置单机模式自动

配置好之后就可以运行测试,和启动普通的Spring Boot聚合项目一样,启动之后直接访问:http://localhost:8848/nacos, 这个时候就能看到我们以前看到的对应客户端页面了,Nacos源码启动完成。

源码启动Nacos

Nacos客户端服务注册源码分析

从源码级别看Nacos是如何注册实例的

Nacos源码模块中有一个 nacos-client ,直接看其中测试类 NamingTest

@Ignore
public class NamingTest {
    
    @Test
    public void testServiceList() throws Exception {
        // 连接nacos server信息
        Properties properties = new Properties();
        properties.put(PropertyKeyConst.SERVER_ADDR, "127.0.0.1:8848");
        properties.put(PropertyKeyConst.USERNAME, "nacos");
        properties.put(PropertyKeyConst.PASSWORD, "nacos");
        
        //实例信息封装,包括基础信息和元数据信息
        Instance instance = new Instance();
        instance.setIp("1.1.1.1");
        instance.setPort(800);
        instance.setWeight(2);
        Map<String, String> map = new HashMap<String, String>();
        map.put("netType", "external");
        map.put("version", "2.0");
        instance.setMetadata(map);
    
        //通过NacosFactory获取NamingService
        NamingService namingService = NacosFactory.createNamingService(properties);
        //通过namingService注册实例
        namingService.registerInstance("nacos.test.1", instance);
    }
}

这就是 客户端注册 的一个测试类,它模仿了一个真实的服务注册进Nacos的过程,包括 Nacos Server连接属性封装实例的创建实例属性的赋值注册实例,所以一段测试代码包含了服务注册的核心代码。

设置Nacos Server连接属性

Nacos Server连接信息,存储在Properties当中:

Properties properties = new Properties();
properties.put(PropertyKeyConst.SERVER_ADDR, "127.0.0.1:8848");
properties.put(PropertyKeyConst.USERNAME, "nacos");
properties.put(PropertyKeyConst.PASSWORD, "nacos");

这些信息包括:

  • SERVER_ADDR :Nacos服务器地址,属性的PropertyKeyConst key为serverAddr
  • USERNAME :连接Nacos服务的用户名,PropertyKeyConst key为username,默认值为nacos
  • PASSWORD :连接Nacos服务的密码,PropertyKeyConst key为passwod,默认值为nacos
服务实例封装

注册实例信息用 Instance 对象承载,注册的实例信息又分两部分:实例基础信息元数据

Instance类-实例信息字段

基础信息字段说明:

  • instanceId:实例的唯一ID;
  • ip:实例IP,提供给消费者进行通信的地址;
  • port: 端口,提供给消费者访问的端口;
  • weight:权重,当前实例的权重,浮点类型(默认1.0D);
  • healthy:健康状况,默认true;
  • enabled:实例是否准备好接收请求,默认true;
  • ephemeral:实例是否为瞬时的,默认为true;
  • clusterName:实例所属的集群名称;
  • serviceName:实例的服务信息。

元数据:

Map<String, String> map = new HashMap<String, String>();
map.put("netType", "external");
map.put("version", "2.0");
instance.setMetadata(map);

元数据 Metadata 封装在HashMap中,这里只设置了 netType 和 version 两个数据,未设置的元数据通过Instance设置的默认值可以get到。

Instance 获取元数据-心跳时间、心跳超时时间、实例IP被剔除的时间、实例ID生成器的方法:

	/**
     * 获取实例心跳间隙,默认为5s,也就是默认5秒进行一次心跳
     * @return 实例心跳间隙
     */
    public long getInstanceHeartBeatInterval() {
        return getMetaDataByKeyWithDefault(PreservedMetadataKeys.HEART_BEAT_INTERVAL,
                Constants.DEFAULT_HEART_BEAT_INTERVAL);
    }

    /**
     * 获取心跳超时时间,默认为15s,也就是默认15秒收不到心跳,实例将会标记为不健康
     * @return 实例心跳超时时间
     */
    public long getInstanceHeartBeatTimeOut() {
        return getMetaDataByKeyWithDefault(PreservedMetadataKeys.HEART_BEAT_TIMEOUT,
                Constants.DEFAULT_HEART_BEAT_TIMEOUT);
    }

    /**
     * 获取实例IP被删除的时间,默认为30s,也就是30秒收不到心跳,实例将会被移除
     * @return 实例IP被删除的时间间隔
     */
    public long getIpDeleteTimeout() {
        return getMetaDataByKeyWithDefault(PreservedMetadataKeys.IP_DELETE_TIMEOUT,
                Constants.DEFAULT_IP_DELETE_TIMEOUT);
    }

    /**
     * 实例ID生成器,默认为simple
     * @return 实例ID生成器
     */
    public String getInstanceIdGenerator() {
        return getMetaDataByKeyWithDefault(PreservedMetadataKeys.INSTANCE_ID_GENERATOR,
                Constants.DEFAULT_INSTANCE_ID_GENERATOR);
    }

Instance获取一些元数据默认值的方法

Nacos提供的元数据key:

public class PreservedMetadataKeys {

    //心跳超时的key
    public static final String HEART_BEAT_TIMEOUT = "preserved.heart.beat.timeout";
    //实例IP被删除的key
    public static final String IP_DELETE_TIMEOUT = "preserved.ip.delete.timeout";
    //心跳间隙的key
    public static final String HEART_BEAT_INTERVAL = "preserved.heart.beat.interval";
    //实例ID生成器key
    public static final String INSTANCE_ID_GENERATOR = "preserved.instance.id.generator";
}

元数据key对应的默认值:

package com.alibaba.nacos.api.common;

import java.util.concurrent.TimeUnit;

/**
 * Constants.
 *
 * @author Nacos
 */
public class Constants {
    //...略
    
    //心跳超时,默认15s
    public static final long DEFAULT_HEART_BEAT_TIMEOUT = TimeUnit.SECONDS.toMillis(15);
    //ip剔除时间,默认30s未收到心跳则剔除实例
    public static final long DEFAULT_IP_DELETE_TIMEOUT = TimeUnit.SECONDS.toMillis(30);
    //心跳间隔。默认5s
    public static final long DEFAULT_HEART_BEAT_INTERVAL = TimeUnit.SECONDS.toMillis(5);
    //实例ID生成器,默认为simple
    public static final String DEFAULT_INSTANCE_ID_GENERATOR = "simple"; 
    
    //...略
}

这些都是Nacos默认提供的值,也就是当前实例注册时会告诉Nacos Server说:我的心跳间隙、心跳超时等对应的值是多少,你按照这个值来判断我这个实例是否健康。

此时,注册实例的时候,该封装什么参数,我们心里应该有点数了。

通过NamingService接口进行实例注册

NamingService 接口是Nacos命名服务对外提供的一个统一接口,其提供的方法丰富:

NamingService接口提供的方法

主要包括如下方法:

  • void registerInstance(...): 注册服务实例
  • void deregisterInstance(...): 注销服务实例
  • List getAllInstances(...): 获取服务实例列表
  • List selectInstances(...): 查询健康服务实例
  • List selectInstances(....List clusters....): 查询集群中健康的服务实例
  • Instance selectOneHealthyInstance(...): 使用负载均衡策略选择一个健康的服务实例
  • void subscribe(...): 服务订阅
  • void unsubscribe(...): 取消服务订阅
  • List getSubscribeServices(): 获取所有订阅的服务
  • String getServerStatus(): 获取Nacos服务的状态
  • void shutDown(): 关闭服务

这些方法均提供了重载方法,应用于不同场景和不同类型实例或服务的筛选。

回到服务注册测试类中的第3步,通过NamingService接口注册实例:

//通过NacosFactory获取NamingService
NamingService namingService = NacosFactory.createNamingService(properties);
//通过namingService注册实例
namingService.registerInstance("nacos.test.1", instance);

再来看一下 NacosFactory 创建namingService的具体实现方法:

/**
  * 创建NamingService实例
  * @param properties 连接nacos server的属性
  */
public static NamingService createNamingService(Properties properties) throws NacosException {
    try {
        //通过反射机制来实例化NamingService
        Class<?> driverImplClass = Class.forName("com.alibaba.nacos.client.naming.NacosNamingService");
        Constructor constructor = driverImplClass.getConstructor(Properties.class);
        return (NamingService) constructor.newInstance(properties);
    } catch (Throwable e) {
        throw new NacosException(NacosException.CLIENT_INVALID_PARAM, e);
    }
}

通过反射机制来实例化一个NamingService,具体的实现类是 com.alibaba.nacos.client.naming.NacosNamingService

NacosNamingService实现注册服务实例

注册代码中:

namingService.registerInstance("nacos.test.1", instance);

前面已经分析到,通过反射调用的是 NacosNamingServiceregisterInstance 方法,传递了两个参数:服务名和实例对象。具体方法在 NacosNamingService 类中如下:

//服务注册,传递参数服务名称和实例对象
@Override
public void registerInstance(String serviceName, Instance instance) throws NacosException {
    registerInstance(serviceName, Constants.DEFAULT_GROUP, instance);
}

该方法完成了对实例对象的分组,即将对象分配到默认分组中 DEFAULT_GROUP

紧接着调用的方法 registerInstance(serviceName, Constants.DEFAULT_GROUP, instance) :

//注册服务
//参数:服务名称,实例分组(默认DEFAULT_GROUP),实例对象
@Override
public void registerInstance(String serviceName, String groupName, Instance instance) throws NacosException {
    //检查实例是否合法:通过服务心跳,如果不合法直接抛出异常
    NamingUtils.checkInstanceIsLegal(instance);
    //通过NamingClientProxy代理来执行服务注册
    clientProxy.registerService(serviceName, groupName, instance);
}

这个 registerInstance 方法干了两件事:

1: checkInstanceIsLegal(instance) 检查传入的实例是否合法,通过检查心跳时间设置的对不对来判断,其源码如下

//类NamingUtils工具类下
public static void checkInstanceIsLegal(Instance instance) throws NacosException {
    //心跳超时时间必须小于心跳间隔时间
    //IP剔除的检查时间必须小于心跳间隔时间
    if (instance.getInstanceHeartBeatTimeOut() < instance.getInstanceHeartBeatInterval()
        || instance.getIpDeleteTimeout() < instance.getInstanceHeartBeatInterval()) {
        throw new NacosException(NacosException.INVALID_PARAM,
                                 "Instance 'heart beat interval' must less than 'heart beat timeout' and 'ip delete timeout'.");
    }
}

2: 通过 NamingClientProxy 代理来执行服务注册。

进入 clientProxy.registerService(serviceName, groupName, instance) 方法,发现有多个实现类(如下图),那么这里对应的是哪个实现类呢?

我们继续阅读NacosNamingService源码,找到 clientProxy 属性,通过构造方法可以知道 NamingClientProxy 这个代理接口的具体实现类是 NamingClientProxyDelegate

NamingClientProxyDelegate中实现实例注册的方法

从上面分析得知,实例注册的方法最终由 NamingClientProxyDelegate 中的 registerService(String serviceName, String groupName, Instance instance) 来实现,其方法为:

/**
  * 注册服务
  * @param serviceName 服务名称
  * @param groupName   服务所在组
  * @param instance    注册的实例
  */
@Override
public void registerService(String serviceName, String groupName, Instance instance) throws NacosException {
    //这一句话干了两件事:
    //1.getExecuteClientProxy(instance) 判断当前实例是否为瞬时对象,如果是瞬时对象,则返回grpcClientProxy(NamingGrpcClientProxy),否则返回httpClientProxy(NamingHttpClientProxy)
    //2.registerService(serviceName, groupName, instance) 根据第1步返回的代理类型,执行相应的注册请求
    getExecuteClientProxy(instance).registerService(serviceName, groupName, instance);
}

//...

//返回代理类型
private NamingClientProxy getExecuteClientProxy(Instance instance) {
    //如果是瞬时对象,返回grpc协议的代理,否则返回http协议的代理
    return instance.isEphemeral() ? grpcClientProxy : httpClientProxy;
}

该方法的实现只有一句话:getExecuteClientProxy(instance).registerService(serviceName, groupName, instance); 这句话执行了2个动作:

1. getExecuteClientProxy(instance): 判断传入的实例对象是否为瞬时对象,如果是瞬时对象,则返回 grpcClientProxy(NamingGrpcClientProxy) grpc协议的请求代理,否则返回 httpClientProxy(NamingHttpClientProxy) http协议的请求代理;

2. registerService(serviceName, groupName, instance): 根据返回的clientProxy类型执行相应的注册实例请求。

**瞬时对象 ** 就是对象在实例化后还没有放到持久化储存中,还在内存中的对象。而这里要注册的实例默认就是瞬时对象,因此在 Nacos(2.0版本) 中默认就是采用gRPC(Google开发的高性能RPC框架)协议与Nacos服务进行交互。下面我们就看 NamingGrpcClientProxy 中注册服务的实现方法。

NamingGrpcClientProxy中服务注册的实现方法

在该类中,实现服务注册的方法源码:

/**
  * 服务注册
  * @param serviceName 服务名称
  * @param groupName   服务所在组
  * @param instance    注册的实例对象
  */
@Override
public void registerService(String serviceName, String groupName, Instance instance) throws NacosException {
    NAMING_LOGGER.info("[REGISTER-SERVICE] {} registering service {} with instance {}", namespaceId, serviceName,
                       instance);
    //缓存当前实例,用于将来恢复
    redoService.cacheInstanceForRedo(serviceName, groupName, instance);
    //基于gRPC进行服务的调用
    doRegisterService(serviceName, groupName, instance);
}

该方法一是要将当前实例缓存起来用于恢复,二是执行基于gRPC协议的请求注册。

缓存当前实例的具体实现:

public void cacheInstanceForRedo(String serviceName, String groupName, Instance instance) {
    //将Instance实例缓存到ConcurrentMap中
    //缓存实例的key值,格式为 groupName@@serviceName
    String key = NamingUtils.getGroupedName(serviceName, groupName);
    //缓存实例的value值,就是封装的instance实例
    InstanceRedoData redoData = InstanceRedoData.build(serviceName, groupName, instance);
    synchronized (registeredInstances) {
        //registeredInstances是一个 ConcurrentMap<String, InstanceRedoData>,key是NamingUtils.getGroupedName生成的key,value是封装的实例信息
        registeredInstances.put(key, redoData);
    }
}

缓存实例的map的key

基于gRPC协议的请求注册具体实现:

//NamingGrpcClientProxy.java
public void doRegisterService(String serviceName, String groupName, Instance instance) throws NacosException {
    InstanceRequest request = new InstanceRequest(namespaceId, serviceName, groupName,
                                                  NamingRemoteConstants.REGISTER_INSTANCE, instance);
    requestToServer(request, Response.class);
    redoService.instanceRegistered(serviceName, groupName);
}
//NamingGrpcRedoService.java
public void instanceRegistered(String serviceName, String groupName) {
    String key = NamingUtils.getGroupedName(serviceName, groupName);
    synchronized (registeredInstances) {
        InstanceRedoData redoData = registeredInstances.get(key);
        if (null != redoData) {
            redoData.setRegistered(true);
        }
    }
}

综上分析,Nacos的服务注册流程:

Nacos服务注册流程

实际微服务项目中是如何进行服务注册的?

以前文创建的 cloud_nacos_provider 项目为例,引入了 spring-cloud-starter-alibaba-nacos-discovery 这个包,先来看一下这个jar的结构:

Spring Boot通过读取 META-INF/spring.factories 里面的监听器类来做相应的动作,看一下客户端的这个 spring.factories 文件的内容:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.alibaba.cloud.nacos.discovery.NacosDiscoveryAutoConfiguration,\
  com.alibaba.cloud.nacos.endpoint.NacosDiscoveryEndpointAutoConfiguration,\
  com.alibaba.cloud.nacos.registry.NacosServiceRegistryAutoConfiguration,\
  com.alibaba.cloud.nacos.discovery.NacosDiscoveryClientConfiguration,\
  com.alibaba.cloud.nacos.discovery.reactive.NacosReactiveDiscoveryClientConfiguration,\
  com.alibaba.cloud.nacos.discovery.configclient.NacosConfigServerAutoConfiguration,\
  com.alibaba.cloud.nacos.loadbalancer.LoadBalancerNacosAutoConfiguration,\
  com.alibaba.cloud.nacos.NacosServiceAutoConfiguration
org.springframework.cloud.bootstrap.BootstrapConfiguration=\
  com.alibaba.cloud.nacos.discovery.configclient.NacosDiscoveryClientConfigServiceBootstrapConfiguration
org.springframework.context.ApplicationListener=\
  com.alibaba.cloud.nacos.discovery.logging.NacosLoggingListener

很显然,Spring Boot自动装配首先找到 EnableAutoConfiguration 对应的类来进行加载,这里我们要看服务时怎么注册的,自然就能想到注册服务对应的是 com.alibaba.cloud.nacos.registry.NacosServiceRegistryAutoConfiguration 这个类。

该类自动注册服务的方法:

@Bean
@ConditionalOnBean({AutoServiceRegistrationProperties.class})
public NacosAutoServiceRegistration nacosAutoServiceRegistration(NacosServiceRegistry registry, AutoServiceRegistrationProperties autoServiceRegistrationProperties, NacosRegistration registration) {
    //实例化一个NacosAutoServiceRegistration
    return new NacosAutoServiceRegistration(registry, autoServiceRegistrationProperties, registration);
}

这里实例化了一个 NacosAutoServiceRegistration 类,它就是实例注册的核心:

protected void register() {
    if (!this.registration.getNacosDiscoveryProperties().isRegisterEnabled()) {
        log.debug("Registration disabled.");
    } else {
        if (this.registration.getPort() < 0) {
            this.registration.setPort(this.getPort().get());
        }
		//调用父类的register
        super.register();
    }
}

那么NacosAutoServiceRegistration的父类是哪个呢?来看一下它的关系图:

也就是说,NacosAutoServiceRegistration 继承了 AbstractAutoServiceRegistrationAbstractAutoServiceRegistration 实现了监听接口 ApplicationListener ,一般情况下,根据经验,该类型的监听类,都会实现 onApplicationEvent 这种方法,我们来看源码验证一下:

public abstract class AbstractAutoServiceRegistration<R extends Registration> implements AutoServiceRegistration, ApplicationContextAware, ApplicationListener<WebServerInitializedEvent> {
    //...略

    //实现监听类的方法
    public void onApplicationEvent(WebServerInitializedEvent event) {
        this.bind(event);
    }

	//具体实现
    public void bind(WebServerInitializedEvent event) {
        ApplicationContext context = event.getApplicationContext();
        if (!(context instanceof ConfigurableWebServerApplicationContext) || !"management".equals(((ConfigurableWebServerApplicationContext)context).getServerNamespace())) {
            this.port.compareAndSet(0, event.getWebServer().getPort());
            //启动
            this.start();
        }
    }

    public void start() {
        if (!this.isEnabled()) {
            if (logger.isDebugEnabled()) {
                logger.debug("Discovery Lifecycle disabled. Not starting");
            }

        } else {
            if (!this.running.get()) {
                this.context.publishEvent(new InstancePreRegisteredEvent(this, this.getRegistration()));
                //调用注册的方法
                this.register();
                if (this.shouldRegisterManagement()) {
                    this.registerManagement();
                }

                this.context.publishEvent(new InstanceRegisteredEvent(this, this.getConfiguration()));
                this.running.compareAndSet(false, true);
            }

        }
    }
    //...略
}

也就是说,项目启动的时候就会触发该类,然后 bind() 调用 start() 然后调用 register() 方法。在 register() 方法处打个断点,debug一下:

可以看到,配置文件中的相关属性被放到实例信息中了。没有配置的,nacos会给默认值,比如分组的默认值就是 DEFAULT_GROUP 等。

那么Nacos客户端将什么信息传递给服务器,我们就明了了,比如nacos server的ip地址、用户名,密码等,还有实例信息比如实例的ip、端口、权重等,实例信息还包括元数据信息(metaData)。

接着往下看,调用的register方法:

protected void register() {
    //调用NacosServiceRegistry的register方法
    this.serviceRegistry.register(this.getRegistration());
}

NacosServiceRegistry 中:

public void register(Registration registration) {
    if (StringUtils.isEmpty(registration.getServiceId())) {
        log.warn("No service to register for nacos client...");
    } else {
        //实例化NamingService
        NamingService namingService = this.namingService();
        //服务id、组信息
        String serviceId = registration.getServiceId();
        String group = this.nacosDiscoveryProperties.getGroup();
        //实例信息封装
        Instance instance = this.getNacosInstanceFromRegistration(registration);
        try {
            //注册实例
            namingService.registerInstance(serviceId, group, instance);
            log.info("nacos registry, {} {} {}:{} register finished", new Object[]{group, serviceId, instance.getIp(), instance.getPort()});
        } catch (Exception var7) {
            if (this.nacosDiscoveryProperties.isFailFast()) {
                log.error("nacos registry, {} register failed...{},", new Object[]{serviceId, registration.toString(), var7});
                ReflectionUtils.rethrowRuntimeException(var7);
            } else {
                log.warn("Failfast is false. {} register failed...{},", new Object[]{serviceId, registration.toString(), var7});
            }
        }

    }
}

注册实例调用的是NamingService的实现类 NacosNamingServiceregisterInstance 方法:

public void registerInstance(String serviceName, String groupName, Instance instance) throws NacosException {
    //检查服务实例设置的心跳时间是否合法
    NamingUtils.checkInstanceIsLegal(instance);
    String groupedServiceName = NamingUtils.getGroupedName(serviceName, groupName);
    if (instance.isEphemeral()) {
        BeatInfo beatInfo = this.beatReactor.buildBeatInfo(groupedServiceName, instance);
        this.beatReactor.addBeatInfo(groupedServiceName, beatInfo);
    }
	//服务注册
    this.serverProxy.registerService(groupedServiceName, groupName, instance);
}

这里就和前面直接从源码看服务的注册过程连接上了,先检查实例的心跳时间,然后调用gPRC协议的代理进行服务注册:

最终调用发送请求 /nacos/v1/ns/instance 实现注册。

Nacos服务注册流程总结

Nacos服务注册流程

注册步骤小结:

  1. 读取Spring Boot装载配置文件 spring.factories,找到启动类 NacosAutoServiceRegistration

  2. NacosAutoServiceRegistration 继承 AbstractAutoServiceRegistration,它实现 ApplicationListener 接口;

  3. 实现ApplicationListener接口的 onApplicationEvent 方法,该方法调用 bind() ,然后调用 start() 方法;

  4. start()方法中调用register(),该方法调用 NacosServiceRegistry 的register方法;

  5. NacosServiceRegistry的register方法内部调用 NacosNamingServiceregisterInstance 方法;

  6. 根据实例的瞬时状态选择不同的proxy执行注册,默认是 gRPC 协议的 NamingGrpcClientProxy 执行注册;

  7. 完成实例注册(POST请求 /nacos/v1/ns/instance)。

最后

本文主要内容是针对Spring Cloud Alibaba组件之注册中心Nacos的介绍,从安装使用到项目实战,最后分析了一波客户端注册服务的源码。

后续将继续分享Spring Cloud Alibaba的其他功能组件的操作,如 SentinelSeata 等,或许还会继续分享一些核心功能的源码,请关注我吧,方便上车不迷路。

本次导航结束。

先赞后看,养成习惯。

举手之劳,赞有余香。

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿

猜你喜欢

转载自juejin.im/post/7118935656004321294