不为有趣之事,何遣有涯之生
不失其所者久,死而不亡者寿

Docker简明教程(8)

hzqiuxm阅读(51)评论(0)

Docker的私有仓库

私有仓库搭建与入门

创建步骤

  • 下载官方的仓库镜像 docker pull registry
  • 启动Docker Registry容器
docker run -d -p 7779:5000 -v /ccuse/myregistry/:/tmp/registry --privileged=true registry
  • 查看Docker Registry进程 docker ps

基本操作

  • 查看Registry仓库中现有的镜像
curl -XGET http://192.168.3.112:7779/v2/_catalog

命令的结果将返回一个镜像的清单

  • 将Docker镜像推到Registry中
//给本地镜像打Tag
docker tag mytomcat9 192.168.3.112:7779/mytomcat9test
//推送镜像到Registry中
docker push 192.168.3.112:7779/mytomcat9test
(会出现错误,因为client与Registry交互默认将采用https访问,但我们在安装Registry时并未配置指定相关的key和crt文件,https将无法访问)
//centos在/etc/sysconfig/docker中做配置
ADD_REGISTRY='--add-registry 192.168.3.112:7779’
INSECURE_REGISTRY=‘--insecure-registry 192.168.3.112:7779’
//ubuntu在/etc/docker/default.json中配置:
{ "insecure-registries":["192.168.3.112:7779"] }
//然后重启docker服务:
service docker restart

WEB管理服务搭建

根据上面官方提供的镜像搭建的私服,只有API的操作方式,对于我们管理自己的镜像不直观也不方便,熟悉nexus私服的都知道,管理一些jar依赖时候可以方便的通过web页面的方式进行。还好我们可以通过hyper/docker-registry-web这个镜像来搭建一个web服务来进行私服镜像的管理。
访问docker的官方,可以搜索到,主要的安装步骤如下(具体的说明请参考官方文档说明https://hub.docker.com/r/hyper/docker-registry-web/):

  • 拉取镜像到本地 docker pull hyper/docker-registry-web
  • 启动:
方式一:
//启动私服镜像服务
docker run -d -p 7779:5000 --name registry-srv registry
//启动web服务
docker run -it -p 7780:8080 --name registry-web --link registry-srv -e REGISTRY_URL=http://registry-srv:5000/v2 -e REGISTRY_NAME=localhost:5000 hyper/docker-registry-web 

方式二:
//带身份验证访问
docker run -it -p 7780:8080 --name registry-web --link registry-srv \
           -e REGISTRY_URL=https://registry-srv:5000/v2 \
           -e REGISTRY_TRUST_ANY_SSL=true \
           -e REGISTRY_BASIC_AUTH="YWRtaW46Y2hhbmdlbWU=" \
           -e REGISTRY_NAME=localhost:5000 hyper/docker-registry-web

方式三:           
//使用配置启动
1 新建配置文件
registry:
  # Docker registry url
  url: http://registry-srv:5000/v2
  # Docker registry fqdn
  name: localhost:5000
  # To allow image delete, should be false
  readonly: false
  auth:
    # Disable authentication
    enabled: false
2 启动
docker run -d -p 7779:5000 --name registry-srv registry
docker run -it -p 7780:8080 --name registry-web --link registry-srv -v $(pwd)/config.yml:/conf/config.yml:ro hyper/docker-registry-web

访问web服务查看仓库中镜像:

为web服务增加删除功能

我们可以看到上面私服中只有查看镜像的操作,没有删除的操作,如果要删除的话,要创建一个给registry用的config.yml,在里面设置可以delete,例如:

version: 0.1
log:
level: info
formatter: text
fields:
service: registry-srv
environment:production

storage:
cache:
layerinfo: inmemory
filesystem:
rootdirectory: /tmp/registry
delete:
# 要在ui 上能够删除镜像,enable 的值必须是true
enabled: true
http:
addr: :5000

关于私服的思考

我们知道容器的一个思想本质是临时的,在容器中修改的东西,等容器重启后不会保留。所以我们自己搭建的镜像私服也是一样,当docker重启后,其中运行的容器也都关闭。所以之前在容器中添加的镜像也就没有了,那私服还有意义吗?怎么更好维护好自己的镜像呢?大家先做个思考,我会在后续章节回答这些问题~

Docker简明教程(7)

hzqiuxm阅读(65)评论(0)

Dockerfile入门与实践技巧

在上一节中演示了一个构建静态网站的例子,里面涉及到了Dockerfile的编写,刚接触的同学可能不太理解,所以专门新增一节介绍下Dockerfile的基础知识和一些编写原则与技巧。
然后以构建一个自己的java+tomcat的镜像为例子

理论部分

Dockerfile是用来构建Docker镜像的构建文件,是由一系列命令和参数构成的脚本。
熟悉linux下Shell脚本同学学起来应该是毫不费力,没有经验的掌握好常用的指令,多看看官网现成的例子也应该很快就上手的。

基本语法讲解

1:每条指令都必须为大写字母,且后面要跟随至少一个参数
2:指令按照从上到下,顺序执行
3:#表示注释
4:每条指令都会创建一个新的镜像层,并对镜像进行提交
5:Docker执行Dockerfile的大致流程
(1)docker从基础镜像运行一个容器
(2)执行一条指令,对容器作出修改
(3)执行类似docker commit的操作,提交一个新的镜像层
(4)docker再基于刚提交的镜像运行一个新容器
(5)执行dockerfile中的下一条指令,直到所有指令都执行完成
6:Docker会将构建镜像的过程缓存起来,如果不需要缓存,可以在docker build的时候指定 --no-cache

指令功能介绍

FROM

指定一个已经存在的镜像,也是构建的基础镜像,Dockerfile的第一条必须是FROM

MAINTAINER

设置作者,联系邮件

RUN

指定要运行的命令,建议使用数组的格式,也是exec的格式,如:
RUN[“apt-get”,”install”,”-y”,”nginx”]

EXPOSE

向容器外部公开容器内的端口

WORKDIR

指定在创建容器的时候,在容器内部设置一个工作目录,entroypoint和CMD指定的
程序会在这个目录执行
可以在docker run中使用-w来覆盖工作目录

USER

指定该镜像以什么样的用户去执行,可以单独指定用户,也可以指定用户和组,格
式:USER uid:gid,可以在docker run中通过-u来覆盖,如果都不指定,默认是root

CMD

指定一个容器启动时要运行的命令,如果指定了多条CMD,只有最后一条会执行
例如:CMD[“/bin/bash”,”-l”]
如果在docker run 后面跟上要执行的命令,会覆盖Dockerfile里面的cmd指定的命令

ENTROYPOINT

也用来指定一个容器启动时要运行命令
1:但是它不会被docker run后面的命令覆盖,而是把docker run指定的任何参数当作参数传
递给entroypoint
2:可以和CMD一起用,比如:

ENTROYPOINT[“/usr/sbin/nginx”]
CMD[“-h”]

这样如果docker run的时候不覆盖CMD,那么就是按照
/usr/sbin/nginx –h来运行
如果运行的时候:docker run –it 容器id –g“daemon off;”
那么实际运行就是:
/usr/sbin/nginx –g “daemon off;”
3:如果非要覆盖entrypoint,可以在docker run的时候,设置 --entroypoint 标志

ENV

用来在构建镜像过程中设置环境变量,例如:
ENV MY_PATH /usr/my
这个环境变量可以在后续的任何RUN指令中使用,这就如同在命令前面指定了环境变
量前缀一样;也可以在其它指令中直接使用这些环境变量,比如:
WORKDIR $MY_PATH
可以在docker run 命令中使用–e来指定环境变量,这些变量只在运行时有效
小技巧:
可以在不需要构建缓存的前面,添加一个ENV语句,这样,要后面更新的时候,就修
改一下这个ENV的值

ADD

用来将构建环境下的文件或目录复制到镜像中。
1:只能操作构建环境相对的文件或目录,文件源也可以使用URL格式
2:如果将一个归档文件指定为源文件,docker会自动解压
3:如果目的位置不存在的话,Docker会自动创建全路径
注意:ADD会使得构建缓存无效,ADD后续指令都不能使用之前的构建缓存了

COPY

类似于ADD,COPY只做构建上下文中复制文件,而不会去做文件提取和解压的工作
如果源文件是目录,那么这整个目录会被复制到容器中

VOLUME

用来向镜像创建的容器添加卷,一个卷是可以存在于一个或者多个容器内的特定目
录,这个目录可以绕过联合文件系统,并提供如下共享数据或者对数据进行持久化:
1:卷可以在容器间共享和重用
2:对卷的修改是立即生效的
3:对卷的修改不影响镜像
卷可以让我们把数据、数据库或其它内容添加到镜像中,而不是将这些内容提交到
镜像中,并且允许我们在多个容器间共享这些内容。
注意:如果删除了最后一个使用卷的容器,内部卷就不见了。
可在docker run的时候,使用-v来把宿主机的目录映射到容器,这样数据就能一直保存了

ONBUILD

指定当镜像做为其它镜像的基础镜像时,该镜像触发执行的功能。
ONBUILD在子镜像build的时候,在FROM之后就先执行,并且只能被执行一次不会被孙镜像继承

Dockerfile编写原则

  • 容器应该是短暂的
  • 避免安装不必要的包
  • 每个容器应该只有一个关注点
  • 最小化层的数量
  • 对多行参数进行排序
  • 缓存中间构建的镜像

实践部分

编写一个Dockerfile文件

FROM ubuntu
MAINTAINER hzqiuxm
#把java与tomcat添加到容器中
ADD jdk-8u111-linux-x64.tar.gz /root/src/soft/
ADD apache-tomcat-9.0.0.M26.tar.gz /root/src/soft/
#配置java与tomcat环境变量
ENV JAVA_HOME /root/src/soft/jdk1.8.0_111
ENV CLASSPATH $JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar
ENV CATALINA_HOME /root/src/soft/apache-tomcat-9.0.0.M26
ENV CATALINA_BASE /root/src/soft/apache-tomcat-9.0.0.M26
ENV PATH $PATH:$JAVA_HOME/bin:$CATALINA_HOME/lib:$CATALINA_HOME/bin
#容器运行时监听的端口
EXPOSE 8080
#启动时运行tomcat
# CMD ["/root/src/soft/apache-tomcat-9.0.0.M26/bin/catalina.sh","run"]
# ENTRYPOINT ["/root/src/soft/apache-tomcat-9.0.0.M26/bin/startup.sh" ]
CMD /root/src/soft/apache-tomcat-9.0.0.M26/bin/startup.sh && tail -F /root/src/soft/apache-tomcat-9.0.0.M26/bin/logs/catalina.out

使用Dockerfile来制作镜像
在/root/src/soft/目录下准备以下文件:1 刚创建的Dockerfile文件 2 jdk压缩包 3 tomcat压缩包,然后执行以下命令

docker build -t mytomcat9 .

构建镜像过程日志如下:

[root@localhost docker]# docker build -t mytomcat9 .
Sending build context to Docker daemon 190.8 MB
Step 1/11 : FROM ubuntu
 ---> 16508e5c265d
Step 2/11 : MAINTAINER hzqiuxm
 ---> Using cache
 ---> f5d67d9ca44a
Step 3/11 : ADD jdk-8u111-linux-x64.tar.gz /root/src/soft/
 ---> 34a20e7958ba
Removing intermediate container 8b7d6c0735ca
Step 4/11 : ADD apache-tomcat-9.0.0.M26.tar.gz /root/src/soft/
 ---> b4ce4316740b
Removing intermediate container b208617a6a63
Step 5/11 : ENV JAVA_HOME /root/src/soft/jdk1.8.0_111
 ---> Running in 1ae8272ac0ef
 ---> 52cc51e6fdcd
Removing intermediate container 1ae8272ac0ef
Step 6/11 : ENV CLASSPATH $JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar
 ---> Running in 91ace6986fed
 ---> a4f53b5778a4
Removing intermediate container 91ace6986fed
Step 7/11 : ENV CATALINA_HOME /root/src/soft/apache-tomcat-9.0.0.M26
 ---> Running in caf3441b162c
 ---> d1595d6ae6e7
Removing intermediate container caf3441b162c
Step 8/11 : ENV CATALINA_BASE /root/src/soft/apache-tomcat-9.0.0.M26
 ---> Running in 6e359bd47512
 ---> 23b7222d20fc
Removing intermediate container 6e359bd47512
Step 9/11 : ENV PATH $PATH:$JAVA_HOME/bin:$CATALINA_HOME/lib:$CATALINA_HOME/bin
 ---> Running in 438a22f75079
 ---> 9c0df759c3ed
Removing intermediate container 438a22f75079
Step 10/11 : EXPOSE 8080
 ---> Running in 994985ed6196
 ---> 2bfbb442a498
Removing intermediate container 994985ed6196
Step 11/11 : CMD /root/src/soft/apache-tomcat-9.0.0.M26/bin/startup.sh && tail -F /root/src/soft/apache-tomcat-9.0.0.M26/bin/logs/catalina.out
 ---> Running in 18b045c67d53
 ---> 41bd1cf525b6
Removing intermediate container 18b045c67d53
Successfully built 41bd1cf525b6

启动容器进行测试访问

docker run -i -t -d -p 9080:8080 --name myt9 -v /ccdockermake/tomcat9/test:/root/src/soft/apache-tomcat-9.0.0.M26/webapps/test -v /tomcat9logs/:/root/src/soft/apache-tomcat-9.0.0.M26/logs --privileged=true mytomcat9

  • 可以进入运行的容器进行相关的操作
docker exec -it 容器id /bin/bash
  • 访问容器的tomcat首页
//我是在虚拟机中运行的,所以ip地址是虚拟机的地址
http://192.168.52.128:9080/

Springboot进阶教程系列之

hzqiuxm阅读(233)评论(0)

Springboot中的MonogoDB多文档事务

前言

NOSQL事务之殇

说到关系数据库的事务,开发同学们一定耳熟能详,原子性、隔离性、一致性、持久性是不是如数家珍?熟悉Spring框架的还深谙在Spring中如何优雅的控制事务。但是我们知道Spring本身没有实现事务,其事务控制也是以来底层关系型数据库的。

对使用MongoDB的同学来说,当然不能使用了。因为在MongoDB里单文档的操作是具备原子性的,多文档就不支持了,所在设计MongoDB数据库的时候,就会用到嵌入和引用来规避事务控制,如果向银行转账之类的操作,无法完全规避的情况下,也是通过自己创建一个容器文档,来做仿真事务的。但是在MongoDB4.0中,支持了我们梦寐以求的多文档事务。

官方消息解读

来看下官方文档中的介绍:

Best way to work with data: adding multi-document ACID transactions, data type conversions, native visualizations with MongoDB Charts, the MongoDB Compass aggregation pipeline builder, and the MongoDB Stitch serverless platform.

Intelligently place data where you need it to be: with 40% faster shard migrations, non-blocking secondary replica reads, SHA-2 authentication, and the new MongoDB Mobile database.

Freedom to run anywhere: bringing global clusters and enterprise security with HIPAA compliance to the MongoDB Atlas database service, along with the free community monitoring service, and Kubernetes integration.

我们可以看到4.0中优化了不少特性,但今天只要关注第一条的开头一句就够了: adding multi-document ACID transactions(增加了多文档ACID事务),接下来我们可以喜大普奔了。

MongoDB新版本提供了面向复制集的多文档事务特性。其能满足在多个操作,文档,集合,数据库之间的事务性,事务的特性:一个事务中的若干个操作要么全部完成,要么全部回滚,操作的原子性A,数据更新的一致性C。事务提交时,所有数据更改都会被永久保存D。事务提交前其数据更改不会被外部获取到。还有个重磅消息:多文档事务在4.0版本仅支持复制(副本)集,对分片集群的事务性支持计划在4.2版本中实现。

不过有个前提,事务的ACID操作必须是针对一个已经存在的集合进行,详细的方法和介绍大家可以去读读官方的文档。我接下来要介绍的是我们如何在Springboot中实现MongoDB的多文档事务操作。

阅读下面的内容需要你具备以下知识:对Springboot有一定的使用经验,了解MongoDB的基本概念掌握基本使用。

环境准备

MongoDB环境准备

  • 版本检查:版本必须是4.0+的,如果不是新安装而是老版本升级的,要检查下自己数据库featureCompatibilityVersion参数是不是4.0
db.adminCommand( { getParameter: 1, featureCompatibilityVersion: 1 } )

如果不是的话设置下:db.adminCommand( { setFeatureCompatibilityVersion: "4.0" } )

  • 副本集群搭建:因为多文档事务前提就是需要多个副本集,所以我们至少准备2个副本集,我这里准备的是在内网环境中的一台机器上采用不同的端口实现多副本集,生产环境下一般都会分开。

主副本:192.168.3.112:27117,辅助副本:192.168.3.112:27217

其中一个的配置文件如下:

port = 27117  端口,另一个是27217
dbpath =../dbs/   存放数据文件,相同机器时要不同
logpath=../logs/mongdb.log  存放日志文件, 相同机器时要不同
logappend=true
fork=true
bind_ip=0.0.0.0
replSet = myrepl   集群名称,自己定义

副本集初始化命令:

rs.initiate({_id:"myrepl",members:[
{_id:0,host:'192.168.3.112:27117'},
{_id:1,host:'192.168.3.112:27217'}]
})

注意:这里的IP不是本机的话,一定要填写内网具体的地址,不能用回写地址127.0.0.1;因为这个IP是要和客户端通信的。

配置成功后可以使用rs.config() 来查看下,如果初始化时候信息填错,可以使用rs.reconfig({...})来重新设置下

{
    "_id" : "myrepl",
    "version" : 2,
    "protocolVersion" : NumberLong(1),
    "writeConcernMajorityJournalDefault" : true,
    "members" : [ 
        {
            "_id" : 0,
            "host" : "192.168.3.112:27117",
            "arbiterOnly" : false,
            "buildIndexes" : true,
            "hidden" : false,
            "priority" : 1.0,
            "tags" : {},
            "slaveDelay" : NumberLong(0),
            "votes" : 1
        }, 
        {
            "_id" : 1,
            "host" : "192.168.3.112:27217",
            "arbiterOnly" : false,
            "buildIndexes" : true,
            "hidden" : false,
            "priority" : 1.0,
            "tags" : {},
            "slaveDelay" : NumberLong(0),
            "votes" : 1
        }
    ],
    "settings" : {
        "chainingAllowed" : true,
        "heartbeatIntervalMillis" : 2000,
        "heartbeatTimeoutSecs" : 10,
        "electionTimeoutMillis" : 10000,
        "catchUpTimeoutMillis" : -1,
        "catchUpTakeoverDelayMillis" : 30000,
        "getLastErrorModes" : {},
        "getLastErrorDefaults" : {
            "w" : 1,
            "wtimeout" : 0
        },
        "replicaSetId" : ObjectId("5b6aca1b7ef20d701c739318")
    }
}

Springboot环境准备

  • 加入依赖:本人比较喜欢使用Gradle来管理包依赖,喜欢Maven的同学可以自己根据其语法配置。
    需要注意的是依赖包的版本是要有严格限制的,因为我们目前使用的是spring-data-mongodb的RC版本,目前稳定版中还不支持,所以drive包必须是3.8的,common包里必须是对应的RC版,我们要把自动依赖的低版本包去除掉。依赖包如下所示:
dependencies {
    compile('org.springframework.boot:spring-boot-starter-data-mongodb'){
        exclude group: 'org.springframework.data', module: 'spring-data-mongodb'
        exclude group: 'org.mongodb', module: 'mongodb-driver'
    }
    compile ('org.springframework.data:spring-data-mongodb:2.1.0.RC1'){
        exclude group: 'org.springframework.data', module: 'spring-data-commons'
    }
    compile ('org.mongodb:mongo-java-driver:3.8.0')
    compile('org.springframework.boot:spring-boot-starter-web')
    compile('org.springframework.data:spring-data-commons:2.1.0.RC1')

数据库查看根据

如果还用客户端直接访问服务端来查看MongoDB集合或文档的话,那效率也太低了,工欲善其事必先利其器嘛。所以免费的工具还是推荐Robo 3T,当然如果你使用的是收费的Studio 3T那真的最好了。

其操作界面如下:

与Springboot结合

用Spring注解方式来控制事务

要使用Spring注解方式控制事务,需要使用使用到MongoDB提供的事务管理类,我们要先配置一个

@Bean
MongoTransactionManager transactionManager(MongoDbFactory dbFactory) {
    return new MongoTransactionManager(dbFactory);
}

编写测试类代码

然后创建好需要测试的控制类,服务类,资源类。篇幅原因就不把各个部分的代码贴上来了,只简单介绍下注意点,源代码请到我的个人仓库查看:https://gitee.com/hzqiuxm/mddemo.git
文档实体类Person.java

@Document
public class Person {
    @Id
    private ObjectId id;
    private String name;
    private int age;
    private List<String> address;
    private Map<String,String> des;
     ......
   }

TestController.java是控制类,里面的方法testAdd()用来测试添加一个文档

@GetMapping("/testAdd")
public void testAdd(){

    Person person = new Person("fengxiaoxiong",29);

    ArrayList<String> strings = new ArrayList<>();
    strings.add("浙江省杭州市西湖区");
    strings.add("浙江省杭州市之江区");
    person.setAddress(strings);

    Map<String,String> descrption = new HashMap<>();
    descrption.put("ext1","my");
    descrption.put("ext2","bear");

    person.setDes(descrption);

    personService.addPerson(person);
}

PersonService 是服务类,自动注入实体操作类,并增加一个addPerson()方法,用来添加一个文档。

@Autowired
private PersonRepository personRepository;
@Transactional
public void addPerson(Person person){
    personRepository.insert(person);
}

我们使用注解@Transctional来控制事务
最后的PersonRespository接口是用来操作数据集集合文档的,继承自MongoDBRepository接口,简单的增删改查方法框架都帮我们生成好了,所以不用增加任何方法。

public interface PersonRepository extends MongoRepository<Person,ObjectId> {
}

创建数据库集合和配置文件

本次用到的数据库名称:sale;集合名称Person
在Springboot工程里application.yml配置文件中加入:

spring:
    data:
      mongodb:
        host: 192.168.3.112  //主副本的地址
        port: 27117          //主副本的端口
        replica-set: myrepl  //集群名称
        database: sale       //数据库名称

注意,这里只要配置主副本的地址和端口就可以了,工程启动的时候我们可以从启动日志看到通过连接主副本后,会解析集群的信息然后分别连接到各个副本

测试

无事务方法中出现异常情况

在服务方法中我们加入一个除0的异常代码

public void addPerson(Person person){
    personRepository.insert(person);
    int a = 5/0;
}

这种情况下,因为没有事务文档是会添加成功的,执行后报java.lang.ArithmeticException: / by zero异常错误,但是我们查看数据库,发现记录被添加了:

/* 6 */
{
    "_id" : ObjectId("5b6bb1a29391ca5a04d13514"),
    "name" : "fengxiaoxiong",
    "age" : 29,
    "address" : [ 
        "浙江省杭州市西湖区", 
        "浙江省杭州市之江区"
    ],
    "des" : {
        "ext2" : "bear",
        "ext1" : "my"
    },
    "_class" : "com.example.md.mddemo.Person"
}

有事务方法中出现异常情况

因为加了事务,所以会避免提交,我们把要把TestController.java中testAdd()代码中插入内容修改下:

Person person = new Person("jinguanghui",31);
ArrayList<String> strings = new ArrayList<>();
strings.add("浙江省杭州市余杭区");
strings.add("浙江省杭州市之江区");
person.setAddress(strings);
Map<String,String> descrption = new HashMap<>();
descrption.put("ext1","so");
descrption.put("ext2","cool");

然后把事务注解加回到服务方法addPerson()上

@Transactional
public void addPerson(Person person){
    personRepository.insert(person);
    int a = 5/0;
}

执行后报java.lang.ArithmeticException: / by zero异常错误,但是我们查看数据库,发现记录未被添加事务控制起作用了

有事务无异常情况

最后我们把产生异常的代码 a=5/0删除掉执行,文档被正常插入了

/* 7 */
{
    "_id" : ObjectId("5b6bb3c49391ca5a788cfcfa"),
    "name" : "jinguanghui",
    "age" : 31,
    "address" : [ 
        "浙江省杭州市余杭区", 
        "浙江省杭州市之江区"
    ],
    "des" : {
        "ext2" : "cool",
        "ext1" : "so"
    },
    "_class" : "com.example.md.mddemo.Person"
}

总结

虽然上面只简单使用了一个除0异常来演示,但是原理上是相同的,我们可以在除0异常后加入其它文档操作,最后效果类似。

通过这个简单示例,我们现在如果要使用MongoDB的多文档事务控制功能,必须要注意下面几点:

  • MongoDB的版本必须是4.0
  • 事务功能必须是在多副本集的情况下才能使用
  • 如果不和其他框架结合使用,直接参考官方文档的显示调用方式
  • 事务控制只能用在已存在的集合中
  • 如果想和Springboot框架结合,借用spring-data-mongodb来简化操作,那目前只能使用RC包,并且要剔除老版本包的依赖,自己添加符合要求的包

相信本次事务功能的更新,对MongoDB数据库模式设计会带来一些变化,嵌入文档,数组结合引用和仿真事务的一些设计会有所变化,一些棘手的问题会在一定程度上得到解决。

最后我们期待分片集群事务早点到来,广大Springboot的使用者们相信更期待spring-data-mongodb稳定版对事务支持早日推出。

JAVA语言系列

hzqiuxm阅读(151)评论(0)

前言

Oracle 官方宣称在每年的三月和九月将分别发布两次 Java 的新版本。这意味着 Java 11 将在五个月内面世。得益于如此频繁的发布速度,只要有新功能便会立即加入到新版的 Java 中,这样一来开发者就可以很快享有 Java 的新功能,而不必再像以前那样等待多年。

Java 10的发布相比于 Java 9 只有短短的六个月,然而我们在 Java 10中看到了大量的新功能。对于即将发布的 Java 11,我们同样期待能够有更多的新功能加入。

如今,这些新功能能够快速在 Java 新版本中发布,那么这些新功能到底有哪些?本文将重点介绍当前正在开发和提及的一些有趣的 Java 新功能。

新特性介绍

局部变量类型推断

Java 10 引入了局部变量类型推断的特性。该特性使用var关键字来定义局部变量,并让编译器根据初始化的方式来确定变量类型。如果你使用过 Java 的 lambda 表达式,那么这个特性对你来说并不会太陌生。

实际上,使用过lambda表达式的你可能已经耳熟能详了:

Function<String, String> foo = (s) -> s.toLowerCase();

上面这段代码你会发现没有必要把 s 定义为 String,它的类型会由编译器自动推断出来。使用 Java 10,你可以编写如下代码:

var list = new ArrayList<String>();

编译器会推断 list 是 ArrayList 类型。使用 var关键字可以帮助你减少一些代码的冗长度,尤其是泛型已经存在于变量初始化或者变量名称中时。它容易获得且易于理解,不必依靠 IDE 来告诉你变量的具体类型。

Java 11会进一步增强这一点,所以var也是 lambda 参数的合法类型,这意味着前面提到的 lambda 也可以写成 (var s) -> s.toLowerCase();。你可能会问为什么可以这么写,实际上,忽略类型的效果是一样的。一个主要的原因是,有一个类型的话意味着你可以注释得更好。

具体哪些情况应该使用var,请阅读:http://openjdk.java.net/projects/amber/LVTIstyle.html

原始字符串

目前另一项增强功能被提出并正在积极研究中,那就是原始字符串文字功能。在原始字符串中,字符串中的每个字符都按原样读取,包括换行符!这个功能对于那些需要大量转义或者跨越多行的字符串来说特别有用。例如,这可以是硬编码的 HTML 或 SQL 查询:

String html = "<html>\n" +
             "  <body>\n" +
             "    <p>Hello World.</p>\n" +
             "  </body>\n" +
             "</html>\n";

有了原始字符串文字特性,以上代码可以写成这样:

String html = `<html>
                <body>
                  <p>Hello World.</p>
                </body>
              </html>
             `;

在新的语法中当,反引号(`)用作原始字符串符号。如果你需要在字符串文本中使用反引号,则只需使用双反引号将字符串包围起来,或者三元、四元反引号也可以,只要开始和结束的反引号数量相同即可。

swich表达式

有关 switch 语句的多项改进正在进行,其中就包括了全开模式匹配。我对 switch 表达式充满期待。如果你曾经写过 switch 语句,那么代码可能是这样的:

int val;
switch (str) {
  case "foo": val = 1; break;
  case "bar": val = 2; break;
  case "baz": val = 3; break;
  default: val = -1;
}

有了 switch 表达式,上面的代码可以简化为:

int val = switch (str) {
  case "foo": break 1;
  case "bar": break 2;
  case "baz": break 3;
  default: break -1;
}

这意味着 break 语句能够获得 switch 表达式的结果值,就像 return 获得方法的返回值一样。在以上例子中,break 是 case 的唯一语句,如果继续简化,一种类似 lambda 语法的写法可以将上述代码改为:

int val = switch (str) {
  case "foo" -> 1;
  case "bar" -> 2;
  case "baz" -> 3;
  default -> -1;
}

如上所述,添加 switch 表达式是模式匹配的一个步骤,它使你不仅能够切换编译时常量,还可以是类型、条件检查等等。

目前 switch 表达式已经实现了,经过测试、代码审查和批准之后,相信将其发布到 JDK 中只是一个时间问题。离 Java 11发布还有足够长的时间,在 Java 11中加入 switch 表达式应该没问题。

其他特性

除了上述提到的语言变化之外,还有很多其他的东西可能在近期或者不远的将来出现在 Java 的新版本中。JEP 的提案越来越多,比如支持枚举泛型类型参数的增强枚举、前文已经提到的模式匹配、动态调用、值类型,等等。

如何将单个文件类作为脚本启动,类似于shell、Perl 或者 Python 脚本在命令行的运行方式一样?Java 后台引擎也进行了很多改进。这包括对 JVM 本身的更改,这样的更改对语言层面不会有影响,例如新的超低延迟 GC,用于与本机代码更好地实现互操作性的 API,改变内部类在 JVM 级别(基于嵌套的访问控制)以及一些其他的改进。

SpringCloud微服务系列(7)

hzqiuxm阅读(223)评论(0)

分布式配置中心Spring Cloud Config

起源

为分布式系统中的基础设施或者微服务提供集中化(统一)的外部配置支持,并实现在线更新。

组成

从之前的示例图上,我们可以看到它也是分为服务端和客户端两部分的,其中服务端就是分布式配置中心,它也是一个独立的微服务应用。用来为客户端提供配置,当然它的配置信息可以是本地的也可以是一个远程的配置仓库(通常我们都会是从一个独立的GIT仓库中获取,你肯定知道GIT是可以做版本管理的)。

需要实现统一管理或在线更新配置的其他微服务,扮演的就是客户端的角色。它们从服务端(配置中心)中获取业务相关的配置内容,会在启动或收到总线通知(下一章节会讲到)的时候去加载配置。

配置与映射

服务端映射关系说明

访问配置信息的URL与配置文件的映射关系有如下几种:

  • /{application}/{profile}[/{label}]
  • /{application}-{profile}.yml
  • /{label}/{application}-{profile}.yml
  • /{application}-{profile}.properties
  • /{label}/{application}-{profile}.properties

application:配置文件中除去最后环境信息的部分
profile:环境
label:分支信息,默认是master分支

示例说明:config-client-dev.properties配置文件中,config-client是application,dev是profile

实际案例

spring:
  application:
    name: config-server
  cloud:
    config:
      server:
        git:
          #仓库配置
          uri: https://github.com/hzqiuxm/SpringcloudConfig
          #相对搜索位置,可以多个
          search-paths: respo
      #分支
      label: master

server:
  port: 8888

github工程下信息:

使用postman工具进行访问:

输入:localhost:8888/config-client/dev
返回信息:

{
    "name": "config-client",
    "profiles": [
        "dev"
    ],
    "label": null,
    "version": "c6d735d09f5733200a02f82b7f89e536d8842fa5",
    "state": null,
    "propertySources": [
        {
            "name": "https://github.com/hzqiuxm/SpringcloudConfig/respo/config-client-dev.properties",
            "source": {
                "democonfigclient.message": "hello spring io",
                "foo": "foo version 1"
            }
        },
        {
            "name": "https://github.com/hzqiuxm/SpringcloudConfig/respo/config-client-dev.yml",
            "source": {
                "name": "hzqiuxm"
            }
        }
    ]
}

输入:localhost:8888/config-client/dev, 注意最后的dev代表是分支

返回:

{
    "name": "config-client",
    "profiles": [
        "dev"
    ],
    "label": "dev",
    "version": "7df1a8d5c8f0b2e2c64f27284a6e26e4e3615296",
    "state": null,
    "propertySources": [
        {
            "name": "https://github.com/hzqiuxm/SpringcloudConfig/respo/config-client-dev.properties",
            "source": {
                "democonfigclient.message": "hello spring io",
                "foo": "foo version 3"
            }
        },
        {
            "name": "https://github.com/hzqiuxm/SpringcloudConfig/respo/config-client-dev.yml",
            "source": {
                "name": "hzqiuxm"
            }
        }
    ]
}

客户端配置映射

  • 注意客户端的配置文件名:bootstrap.properties(.yml)
  • spring.application.name={application部分}
  • spring.application.config.profile={profile部分}
  • spring.application.config.label={label部分}
  • spring.application.config.uri={配置中心的地址,带端口}

原理

完整基础架构

之前的示例其实还有一个不完整的地方,就是配置中心在获取了GIT仓库中的配置后,会在本地存储一份,这样不用每次都去远程仓库去取了,即便远程仓库暂时无法访问,也可以保证能返回配置信息给客户端。

完整的基础架构示例图如下:

客户端从配置管理中获取配置流程:
1 客户端根据bootstrap.properties文件配置信息,向配置中心请求配置
2 配置中心查找客户端需要配置信息
3 通过git clone 命令将远程git仓库获取的配置下载到本地的git仓库中
4 配置中心创建spring的ApplicationContext实例,从本地获取配置信息给客户端
5 客户端得到配置文件,加载到ApplicationContext实例中(该配置优先级高于jar包内部的配置)

Git仓库配置

  • 占位符配置: 在配置文件中使用{applicaion}方式配置,服务端会根据客户端的spring.application.name来填充(类似的还有其他几种占位符)

    最佳实践:代码库和配置库名称统一,比如代码库为user-service的微服务的配置库就是user-service-config,这样配置的时候就可以使用占位符{application}来实现通用的配置了

  • 多仓库配置:除了使用application和profile模式陪陪配置仓库外,还可以使用通配符的模式,在配置文件中配置多个仓库来应对复杂的配置需求(建议不要太复杂,维护困难)

  • 指定本地仓库位置:spring.cloud.config.server.git.basedir来配置一个我们准备好的目录
  • 属性覆盖:实现服务端和客户端共同配置,客户端也可以覆盖。通过spring.cloud.config.server.overrides属性来设置键值对参数
  • 安全保护:结合SpringSerurity实现用户名密码访问

加密解密

为了保证数据库账户密码的安全性,不能把明文暴露给开发人员,所以需要对密码进行加密

配置成:{cipher}xxxxxxxxxxxxxxxxxx 的值时,因为{cipher}前缀的作用,配置中心会自动为该值进行解密

准备阶段
  • 去除密钥长度限制:因为需要用到AES加密,而JDK自带的算法密钥长度是有限制,我们要先下一个授权文件,去除长度限制
    > JCE下载:https://www.oracle.com/search/results?Nty=1&Ntk=S3&Ntt=JCE 根据自己的JDK版本选择对应版本解压的授权文件:local_policy.jarUS_export_policy.jar复制到$JAVA_HOME/jre/lib/security目录下,覆盖掉原来内容即可
  • 通过新端点验证:GET访问/encrypt/status 端点,提示你 “NO_KEY”就说明成功了
  • 配置密钥:在配置文件增加 encrypt.key=xxxx xxx是你设置的密钥(更好的方式是使用环境变量ENCRYPT_KEY进行外部存储配置)
  • 使用:使用/encrypt/decrypt端点对请求的body内容进行加解密,它们是post请求

默认采用的是AES对称加密算法,当然也可以采用非对称加密来实现,这就需要我们自己生成密钥对,配置中加入密钥对的位置信息

高可用配置

传统方式

不需要为服务端做任何的额外的配置,所有的服务端都指向一个GIT仓库,通过nginx等其它软件或硬件来实现负载均衡

微服务模式

将服务端作为微服务注册到注册中心,这样客户端调用的时候就可以去注册中心查询,再通过声明式调用或者Riboon来实现客户端的负载均衡

增加配置,注册到注册中心:

eureka:
  client:
    service-url:
      #注册中心地址
      defaultZone: http://localhost:8761/eureka/

客户端相关说明

URL指定配置中心

  • 只有我们配置了spring.cloud.config.uri的时候,客户端才会尝试去连接服务端来获取远程配置信息
  • 配置必须是在bootstrap.properties文件中

失败快速响应与重试

如果你想客户端优先判断能否从服务端获取配置,并快速响应失败的内容,增加下面参数配置:

spring.cloud.config.failFast=ture;

如果只是因为网络波动导致的失败,那代价未免大了些,所以我们希望最好能够自动重试,避免是网络波动原因造成的偶发失败。

开启自动重试需要依赖spring-retry和spring-boot-starter-aop,当然上面失败快速响应的配置也是要的。无需其他额外的配置,这样就实现了失败的自动重试(默认会重试6次,你可以对最大重试次数和重试间隔进行配置)

动态刷新配置

有时候我们需要对配置内容做一些实时更新,这个时候需要引入spring-boot-starter-actuator监控模块,我们主要是用该模块的/refresh端点进行实现的,该端点将用于实现客户端应用配置信息的重新获取与刷新。

熟悉Git仓库的人可能就想到了,我们能够和Web Hook进行关联,这样Git配置变化时,就主动给配置主机发送/refresh请求,实现配置的自动更新(不过维护刷新清单是件烦人的事情,最好的解决方案是通过消息总线)。

简单示例

配置中心(服务端)端口:8888
客户端端口:8881
注册中心端口:8761

远程GIT仓库配置:
master分支下的dev配置文件内容

客户端访问主要代码,简单的获取一个参数值:

@EnableDiscoveryClient
@SpringBootApplication
@RestController
@RefreshScope
public class ConfigClientApplication {

   public static void main(String[] args) {
      SpringApplication.run(ConfigClientApplication.class, args);
   }

   @Value("${foo}")
   String foo;


   @RequestMapping(value = "/hi")
   public String hi(){
      return foo;
   }
}

访问:localhost:8881/hi
返回响应: foo version 1

修改远程仓库内容的值:

发送POST请求: localhost:8881/refresh
返回响应:

[
    "config.client.version",
    "foo"
]

如果响应不成功,提示没有权限的话,在配置中增加:
management.security.enabled = flase,其默认是true

重新访问:localhost:8881/hi
返回响应: foo version 1 add something

SpringCloud微服务系列(6)

hzqiuxm阅读(736)评论(0)

API网关Zuul

根据我们前几章介绍的SpringCloud微服务组件已经可以搭建一个功能比较完善的服务架构了,如下图所示:

使用Eureka的集群实现高可用的服务注册中心,各服务间的负载均衡使用Ribbon或Feign来包装实现,
对于依赖的服务调用使用Hystrix来进行包装,实现线程隔离并加入熔断机制,避免服务的雪崩效应。
微服务A可以看做是对外的服务,通常也称之为边缘服务,它的负载一般是通过软负载或者硬负载来实现。

看起来一切都似乎很不错,而且我们可以保证,上面的系统架构的确是没有问题的。但是是否存在不完美的地方呢?
我们可以从二个角度来看看:

  • 开发人员:一般情况下,对外的服务部分应该需要考虑一些安全性。所以我们肯定会在A服务访问中加入一些权限的校验机制,比如校验用户登录状态,token令牌等。要注意的是我们采用的是分布式部署,A服务背后依赖的所有服务,我们可能都要加入这些校验逻辑,假设新增一个C服务,那么A服务就要改造,下次新增一个D服务,A服务又要改造;而且A服务的改造影响范围是巨大的,完全背离了设计准则中的开闭原则。还有一个是随着服务的增加,A服务的校验逻辑也将越来越复杂,不仅增加了开发难度,每次测试工作量也越来越大。

  • 运维人员:我们可以看到A服务的负载是由nginx这类软负载或者F5这类硬负载实现的,运维人员必须人工的维护这种路由规则对应的服务实例列表。当有实例发生增减或者IP地址(使用域名可以避免这个问题)变动的时候,也需要手工去同步修改这些信息,保证实例信息与中间件配置的一致性。当系统规模不大时,这个工作量可能还好,一旦系统规模到达3位数,人工维护将变得非常困难,很容易出错。

所以为了解决上面的问题,使得我们的微服务架构更加完美,就需要引入API网关Zuul。

基本介绍

API网关类似设计模式中的Facade(门面)模式,所有的外部客户端访问都需要经过它来进行调度和过滤。它负责实现请求路由、负载均衡、校验过滤、请求转发熔断机制、服务聚合等功能。在SpringCloud的微服务中,API网关的解决方案是Zuul。它作为一个服务注册到Eureka注册中心中,这样就可以获取注册中心的其他微服务实例,从未实现了对路由规则与服务实例维护问题;Zuul提供了一套过滤器的机制,开发者可以利用过滤器机制做一些过滤,拦截和校验工作,大大降低了开发难度。

快速入门演示

我们接下来会搭建一个简单的网关应用,架构中会用到我们之前的一些项目模块,我们知道在springCloud微服务系统中,客户端是既可以作为消费者又可以作为生产者的。所以大家不用关心各个功能间的调用关系,只要把它们看成都是网关服务的提供者就可以了。整体架构如下:

  • 构建网关Zuul
    第一步:引入依赖
compile('org.springframework.cloud:spring-cloud-starter-zuul')

我们观察下这个依赖包装了哪些依赖:

我们可以发现,SpringCloud的zuul是对Netflix-zuul做了封装,还加入了负载均衡Ribbon,熔断保护Hystrix,springboot中的端点管理actuator。

第二步:开启注解,使用@EnableZuulProxy开启Zuul的API网关功能

@EnableZuulProxy
@EnableEurekaClient
@SpringBootApplication
public class ServiceZuulApplication {

   public static void main(String[] args) {
      SpringApplication.run(ServiceZuulApplication.class, args);
   }
}

第三步:增加配置

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/
server:
  port: 8769
spring:
  application:
    name: service-zuul
zuul:
  routes:
    api-a:
      path: /api-a/**
      serviceId: service-ribbon
    api-b:
      path: /api-b/**
      serviceId: service-feign

配置的时候我们可以使用url的方式,具体指定某个URL地址对应的实例,但是不推荐,我们应该采用面向服务的路由配置方式,通过serviceId配置服务。

注意:采用URL配置实例的方式是不会使用HystrixCommand进行包装的,所以就丧失了线程隔离和断路器保护,负载均衡的能力。

  • 请求过滤:之前说过,网关是要负责权限校验等操作的,这里为了演示方便就做了简单的token验证
@Component
public class MyFilter extends ZuulFilter{

    private static Logger log = LoggerFactory.getLogger(MyFilter.class);

    @Override
    public String filterType() {
        return "pre";
    }

    @Override
    public int filterOrder() {
        return 0;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        HttpServletRequest request = ctx.getRequest();
        log.info(String.format("%s >>> %s", request.getMethod(), request.getRequestURL().toString()));
        Object accessToken = request.getParameter("token");
        if(accessToken == null) {
            log.warn("token is empty");
            ctx.setSendZuulResponse(false);
            ctx.setResponseStatusCode(401);
            try {
                ctx.getResponse().getWriter().write("token is empty");
            }catch (Exception e){}

            return null;
        }
        log.info("ok");
        return null;

    }
}

关于过滤器的知识,在文章后面会有详解,这里大家只要知道我们可以通过继承ZuulFilter来实现自定义的过滤器。四个方法定义如下:

  • filterType:过滤器类型,决定过滤器在请求的哪个生命周期执行,pre代表在路由之前
  • filterOrder:过滤器执行顺序,根据其返回值决定执行顺序
  • shouldFilter:是否需要被执行
  • run:过滤器的具体执行逻辑

启动各个服务:所有服务清单如下

注意:8763服务是包含了随机睡眠时间的,所以调用的时候会超时报错

  • 输入测试

通过Ribbon访问服务的URL:localhost:8769/api-a/hi?name=hzqiuxm&token=qiuxm
通过Feign访问服务的URL:localhost:8769/api-b/hi?name=hzqiuxm&token=qiuxm

上面两个URL可能返回的结果:

hi hzqiuxm, i am from port8762
{
    "timestamp": 1525409450143,
    "status": 500,
    "error": "Internal Server Error",
    "exception": "com.netflix.zuul.exception.ZuulException",
    "message": "GENERAL"
}
hi hzqiuxm, i am from port8763
hi hzqiuxm, i am from port8762

当URL中不包含token的时候,不会发生调用,网关就直接返回:token is empty
通过简单的示例,我们现在可以理解API网关带来的好处:
1 作为系统统一入口,屏蔽了系统内部各个微服务的调用细节
2 可以与服务治理框架结合,可以实现自动化的实例维护、负载均衡、路由转发。
3 可以实现权限校验与业务逻辑的解耦
4 保证了微服务无状态,易于扩展和测试
5 通过配置实现路由规则,易于测试维护

路由详解

在上面的简单示例中提及,我们要以面向服务方式来配置理由规则。接下来就讲讲路由配置的一些匹配规则个配置要点。

服务路由配置

对于面向服务的路由配置,我们可以采用上面配置中path和serviceId映射配置方式外,还可以采用一种更简洁的方式:zuul.routes.= 比如上面例子中ribbon的配置就等价于:

zuul:
  routes:
    service-ribbon: /api-a/**

相信大家都有一个疑问,我们只配置了服务名称,那zuul网关接收到外部请求,是如何解析并转发到服务的具体实例上的呢?其实在前面已经埋下了伏笔,我们引入依赖的时候特地观察了下zuul所封装的依赖,里面就包含了ribbon。zuul会从注册中心获取所有服务以及它们的实例清单,所以在Eureka的帮助下,网关本身就已经维护了serviceId与实例地址的映射关系。它会根据Ribbon负载策略选择一个具体的实例进行转发,从而完成路由的工作。

服务路由的默认规则

在Zuul构建网关中引入Eureka后,它会为每个服务都自动创建一个默认的路由规则,这些路由规则的path就是使用serviceId配置的服务名作为请求前缀的。
例如:zuul.routes.service-ribbon.serviceId=user-service服务的默认路由规则就是zuul.routes.service-ribbin.path=/user-serviceId/**,所以当你的服务中心有一个service-ribbon服务的时候。上面这两个配置映射就是默认存在的。

默认有时候是比较方便,但是如果我们不希望zuul自动创建类似上面的映射关系的话,我们可以使用zuul.ignored-services参数来设置一个服务名表达式来定义不进行自动创建。
比如:zuul.ignored-services =* 禁止对所有服务自动创建路由规则。

自定义路由映射规则

通过默认规则我们知道,zuul默认生成的path和以serviceId为前缀的,但是如果我们的服务类似带有版本号的,比如:userservice-v1,如果是默认的,那么对应的path就是/userservuce-v1,熟悉REST风格的开发人员知道,一般带有版本号的url是这样的:/v1/userservice/,如果要满足这个需求,我们就要自定义路由映射规则了。步骤也很简单,只要网关程序中增加一个Bean即可:

@Bean
public PatternServiceRouteMapper serviceRouteMapper(){

   return new PatternServiceRouteMapper(
         "(?<name>^.+)-(?<version>v.+$)",
         "${version}/${name}");
}

PatternServiceRouteMapper对象可以通过正则表达式方式来自定义服务与路由映射关系,当匹配不上时,还是会使用默认的路由规则的。

路径匹配

路由匹配路径的表达式,我们可以采用通配符的方式:
? :匹配任意单个字符
* :匹配任意数量的字符
** : 匹配任意数量的字符,支持多级目录

那它们的优先顺序是怎么保证的呢?路由的规则加载算法是通过LinkedHashMap来保存的,说明规则保存是有序的,但是内容的加载是通过线性遍历配置文件来依次加入的。
所以我们要注意properties的配置内容无法保证有序,YAML的文件可以保证有序。

除此之外我们还可以通过zuul.ignored-patterns设置让API网关忽略的URL表达式;可以通过zuul.prefix全局为路由增加前缀信息;使用forward来进行本地跳转

Cookies与头信息

在默认情况下,Zuul会过滤掉HTTP请求头中的一些敏感信息,默认的敏感头信息通过zuul.sensitiveHeaders参数定义。cookies默认在网关中是不会传递的,所以当我们使用了Spring Security、Shiro等安全框架构建路由时,由于cookies无法传递,web应用将无法鉴权。我们有二种方式可以解决:

  • 全面覆盖:设置一个空值,覆盖掉原来的默认值(不推荐)
zuul.sensitiveHeaders=
  • 指定路由的参数配置:方式有二种(推荐)
zuul.routes.<router>.customSensitiveHeaders=true  //对指定的路由开启自定义敏感头
或者
zuul.routes.<router>.sensitiveHeaders=     //将指定路由的敏感头设置为空

断路保护与负载

在上面我们就提到,采用URL配置实例的方式是不会使用HystrixCommand进行包装的,所以就丧失了线程隔离和断路器保护,负载均衡的能力。所以我们应该都采用面向服务的方式来进行配置。我们也可以通过Hystrix和Ribbon的参数来调整路由请求的各种超时时间。

hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds    //执行超时时间
ribbon.ConnectTimeout   //路由转发请求时,创建请求连接的超时时间,应该小于断路器超时时间
ribbon.ReadTimeout      //路由转发请求的超时时间,也应该小于断路器超时时间
zuul.retryable = false            //全局关闭自动重试机制
zuul.routes.<route>.retryable = false    //指定路由关闭自动重试机制

过滤器详解

过滤器简介

过滤器是Zuul中的核心,一个Zuul网关主要就是对请求进行路由和过滤。路由功能负责将外部请求转发到具体的微服务实例上,过滤功能负责对请求的处理过程进行干预,是实现请求校验、服务聚合等功能的基础。实际上路由转发也是通过过滤器来完成的。每一个进入Zuul的HTTP请求都会经过一系列的过滤器处理链得到请求响应并返回客户端的。

Zuul过滤器必须包含4个基本特征:过滤类型、执行顺序、执行条件、具体操作。在之前的示例中我们已经看到过,其实它们就是ZuulFilter抽象类和IZuulFilter接口定义的抽象方法:

boolean shouldFilter();
Object run();
abstract public String filterType();
abstract public int filterOrder();
  • shouldFilrer:是否要执行该过滤器
  • run:过滤器的具体逻辑,我们可以实现自定义的逻辑
  • filterType:过滤器类型

    pre: 在请求被路由之前调用
    routing:在路由请求时调用
    post:在routing和error之后调用
    error:处理请求发生错误时调用

  • filterOrder:过滤器执行顺序,数值越小优先级越高

下面是一个请求在各个过滤器中生命周期:

  • 外部请求过来时,首先进入第一个阶段pre,主要是对路由前的请求做一些前置加工,比如:请求校验。
  • 完成pre类过滤器处理后,请求进入第二个阶段:routing,该阶段是路由转发阶段,将请求转发到具体的服务实例的过程,当服务实例将请求结果返回之后,该阶段完成、
  • 完成routing后,进入第三个阶段:post,该阶段不仅可以获取到请求信息,还可以获取到服务实例的返回信息。所以可以对结果进行加工转换后返回给客户端
  • error是一个特殊阶段,在上述三个阶段中发生异常时就会触发,但是要注意,它最后是流向post阶段的,因为post才能将结果返回给客户端。

核心过滤器

下面对上面提到的几个过滤器,我们详细了解下,有助于我们更好的理解Zuul以及自定义自己业务逻辑。
我们可以前往org.springframework.cloud.netflix.zuul.filters包下查看每种过滤器类型下包含的过滤器。

pre过滤器

  • ServletDetectionFilter,执行顺序-3,是最先被执行的过滤器。用来检测请求是否是通过Spring的DispatcherServlet处理运行的。
    一般情况下,发送到API网关的外部请求都是通过Spring的DispatcherServlet处理的。
    除了通过/zuul/*路径访问的请求,会绕过DispatcherServlet,被ZuulServlet处理,主要是用来处理大文件的上传,这个路径可以通过zuul.servletpath参数进行配置。

  • Servlet30WrapperFilter,执行顺序-2,是第二个执行的过滤器。目前对所有请求生效,主要将原始HttpServletRequest包装成为Servlet30RequestWrapper对象。

  • FormBodyWrapperFilter,执行顺序-1,是第三个执行的过滤器。主要目的讲符合要求的请求体包装成FormBodyRequestWrapper对象。它只对两类请求生效,通过ContentType判断:

    1.application/x-www-from-urlencoded(会将表单内的数据转换为键值对)
    2.multipart/form-data(既可以上传文件,也可以上传键值对,它采用了键值对的方式,所以可以上传多个文件。)且是 DispatcherServlet处理的

  • DebugFilter,执行顺序是1,是第四个执行的过滤器。主要提供debug信息,辅助分析问题。

  • PreDecorationFilter,执行顺序是5,是pre阶段最后执行的过滤器。主要是根据上下文参数对请求进行预处理。
routing过滤器

  • RibbonRoutingFilter,执行顺序10,是route阶段第一个执行的过滤器。只对请求上下文中有serviceId参数的请求进行处理,换句话说就是只对面向服务的配置生效。主要目的是进行请求转发。

  • SimpleHostRoutingFilter,执行顺序100,是route阶段第二个执行的过滤器。只对url配置路由规则生效,一般不建议这么配置,因为不具备线程隔离和断路器功能。

  • SendForwardFilter,执行顺序500,是route阶段第三个执行的过滤器。只处理请求上下文中存在forward.to参数的请求,处理路由规则中forward本地跳转配置。

post过滤器

  • SendErrorFilter,执行顺序0,是post阶段第一个执行的过滤器。需要请求上下中,要有error.status_code参数,所以在自己设置的异常中要设置该参数。主要是用来处理有错误的请求响应。

  • LocationRewriteFilter,执行顺序900。主要目的将location头重写为zuul的URL。

  • SendResponseFilter,执行顺序1000,是post阶段最后一个执行的过滤器。主要目的是利用上下文的响应信息来组织需要发送各个客户端的响应内容。

异常处理

自定义实现继承ZuulFilter类,对自定义过滤器中处理异常的两种解决方法:
1 通过在阶段过滤器中增加try-catch块,实现内部异常处理。注意一定要设置error.status.code参数才会被SendErrorFilter处理,该种方式是对开发人员基本要求。
2 利用自定义的ErrorFilter类处理,利用error类型过滤器的生命周期特点,集中处理其它几个阶段抛出的异常信息。继承ZuulFilter过滤器,指定为error类型,在run()方法中也要设置error.status.code。该种方式作为第一种方式的补充,防止意外情况发生。

error过滤器问题

前面3个阶段出错时,都会走到这个过滤器中。但是最后错误参数起作用的关键是在post阶段的SendErrorFilter过滤器里,所以在error处理之后,还要进入到post的处理阶段才能生效,但是post阶段本身出错后,是不会进入post阶段的。

解决方案:
1 直接实现error过滤器时,组织实现(不推荐,这样错误返回代码会有多处)
2 依然交给SendErrorFilter来处理(继承SendErrorFilter类,复用run方法,重写类型,执行顺序要大于SendErrorFilter,执行条件里只执行post阶段产生的异常即可,要做到判断哪个阶段的异常,需要对过滤器的核心处理器FilterProcessor进行扩展,实现自定义的过滤器处理器,并记录下该信息)

注意:要使扩展的过滤器处理类生效,需要调用FilterProcessor.setProcessor(新的过滤器处理器)方法来启动。

自定义异常信息

默认的错误信息一般并不符合系统设计的响应格式,所以我们要对返回的异常信息进行定制。
自定义异常信息两种方法:
1 编写一个自定义的post过滤器(类似重写SendErrorFilter实现),自己组织响应结果
2 不采用重写方式,可以对/error端点实现,通过自定义实现一个错误属性类覆盖默认的ErrorAttribute.class

下图是对核心过滤器主要信息的一个汇总思维导图:

核心处理器与禁用过滤器

FilterProcessor负责管理和执行各种过滤器,自定义的过滤器处理器,需要设置后才能生效,通过setProcessor()方法来设置。
Zuul.<SimpleClassName过滤器类型>.<FilterType过滤器类型>.disable=true来进行设置禁用某个过滤器
例子:zuul.AccessFilter.pre.disable=true

动态加载

我们可以动态修改路由规则,动态添加和删除过滤器。

动态路由

这个需要依赖下一章节中介绍的配置中心来实现,通过配置中心,类似配置文件动态刷新。

动态过滤器

本身过滤是通过编码实现的,我们可以借助JVM实现的动态语言,比如Groovy来实现。
目前应该还是一个半成品,处理一些简单的过滤功能,应该没有问题,目前还是不要大规模的进行使用。

网关的高可用

网关的高可用一般由二种方式:
一种是网关客户端不注册到注册中心,比较多的服务网关就是直接提供给外部调用的,所以采用这种方式,架构图如下:

一种是网关客户端也注册到注册中心,这样就不需要额外的软负载或者硬负载了,直接都由客户端负载来实现,架构如下:

网关小结

  • Zuul网关路由规则的配置要采用面向服务的配置,学习了配置中心后,我们就可以实现动态路由了
  • 掌握请求在网关中的生命周期
  • 了解Zuul网关中的核心过滤器
  • 掌握自定义各种类型的过滤器满足实际业务场景需求
  • 掌握自定义异常处理的两种方法
  • 掌握自定义异常信息的两种该方法
  • 掌握自定义过滤器处理器定义
  • 掌握网关的高可用架构设置

SpringCloud微服务系列(5)

hzqiuxm阅读(332)评论(0)

声明式调用

这一章的内容比较简单,大部分的知识基础是前面的两章。
还记得我们第一篇文章概念入门中Ribbon,Hystrix,Fegin三者的图标吗?我再展示一遍(按前面所提及顺序):

相信大家都看出来了,后面Feign图标是通过前面二者结合产生的,为什么?
因为Fegin就是对Ribbon和Hystrix(文章中这些组件默认都是指SpringCloud下的组件而非Netflix下的)的整合封装,同时还扩展了SpringMVC注解,提供给了一种声明式的web服务客户端定义方式。
通过整合封装,大大减少了我们学习使用它的成本,同时Fegin还提供了插拔式的组件:编码器、解码器等。

基本介绍

简单示例

我们下面实现一个简单的带有Hystrix功能的Fegin示例

实现步骤
  • 添加依赖
org.springframework.cloud:spring-cloud-starter-feign
org.springframework.cloud:spring-cloud-starter-hystrix

  • 编写代码

启动类:

@EnableDiscoveryClient
@EnableFeignClients
@SpringBootApplication
@EnableHystrix
public class ServiceFeignApplication {

   public static void main(String[] args) {
      SpringApplication.run(ServiceFeignApplication.class, args);
   }
}

使用@EnableFeignClient指定为声明式调用,其他几个注解相信大家都已经了解了。

服务接口:

/**
 * Copyright © 2017年 ziniuxiaozhu. All rights reserved.
 *
 * @Author 临江仙 hzqiuxm@163.com
 * @Date 2017/12/16 0016 16:59
 * 定义一个feign接口,通过@ FeignClient(“服务名”),来指定调用哪个服务
 * feign是自带断路器的,并且是已经打开了只需要在SchedualServiceHi接口的注解中加上fallback的指定类
 */
@FeignClient(value = "service-hi", fallback = SchedualServiceHiHystric.class)
public interface SchedualServiceHi {

    @RequestMapping(value = "/hi",method = RequestMethod.GET)
    String sayHiFromClientOne(@RequestParam(value = "name") String name);
}

只要创建一个接口,加上注解@FeginClient,使用value属性指定服务名(不区分大小写),fallback指定降级服务类即可。

熔断执行的降级服务类:

/**
 * Copyright © 2017年 ziniuxiaozhu. All rights reserved.
 *
 * @Author 临江仙 hzqiuxm@163.com
 * @Date 2017/8/5 0005 16:33
 * 服务调用失败或者断路器打开后调用该类的方法返回
 */
@Component
public class SchedualServiceHiHystric implements SchedualServiceHi {
    @Override
    public String sayHiFromClientOne(String name) {
        return "sorry " + name;
    }
}

服务降级类需要实现之前的服务接口,方法名参数也要保持一致。

访问接口:

/**
 * Copyright © 2017年 ziniuxiaozhu. All rights reserved.
 *
 * @Author 临江仙 hzqiuxm@163.com
 * @Date 2017/12/16 0016 17:02
 */
@RestController
public class HiController {

    @Resource
    private SchedualServiceHi schedualServiceHi;

    @RequestMapping(value = "/hi",method = RequestMethod.GET)
    public String sayHi(@RequestParam String name){
        return schedualServiceHi.sayHiFromClientOne(name);
    }

}

  • 添加配置
eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/
server:
  port: 8765
spring:
  application:
    name: service-feign
feign:
  hystrix:
    enabled: true

测试验证

启动之前的注册中心,2个服务提供者(其中一个是随机睡眠超时的),各服务架构如下:

访问URL链接:localhost:8765?name=hzqiuxm,将会得到类似下面结果:

hi hzqiuxm, i am from port8763
sorry hzqiuxm
hi hzqiuxm, i am from port8763
hi hzqiuxm, i am from port8762
sorry hzqiuxm

参数绑定

之前的例子中,我们都是提供了一个参数,在实际项目使用中,一般会带上自定义的对象或多个参数,下面就对之前的服务提供者和Feign进行修改,演示下多个参数下,如何进行调用与参数绑定

  • 对原来的服务提供者进行改造,添加几个方法:
@RestController
@EnableEurekaClient
@SpringBootApplication
@EnableDiscoveryClient
public class EurekaclientApplication {

   private static Logger logger = LoggerFactory.getLogger(EurekaclientApplication.class);

   public static void main(String[] args) {
      SpringApplication.run(EurekaclientApplication.class, args);
   }

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

   @RequestMapping("/hi")
   public String home(@RequestParam String name) throws InterruptedException {

      //测试超时效果,加入一个睡眠时间
      int sleepTIme = new Random().nextInt(4000);
      logger.info("sleepTime = " + sleepTIme);

      Thread.sleep(sleepTIme);

      return "hi " + name +", i am from port" + port;
   }

   @GetMapping(value = "/hibyAge")
   String sayHiFromClientOne(@RequestHeader String name, @RequestHeader Integer age){

      return new User(name,age).toString()+", i am from port" + port;
   }

   @PostMapping(value = "/hibyUser")
   String sayHiFromClientOne(@RequestBody User user){

      return "hi, " + user.getName() + ", " + user.getAge()+", i am from port" + port;
   }
}

我们增加了一个自定义的User对象,对象本身很简单,只包含了name和age属性,就不罗列代码了,唯一要注意的是记得带上默认构造函数。

  • 修改Feign中调用,包含接口层和服务层
接口层:
public class HiController {

    @Resource
    private SchedualServiceHi schedualServiceHi;

    @RequestMapping(value = "/hi",method = RequestMethod.GET)
    public String sayHi(@RequestParam String name){
        return schedualServiceHi.sayHiFromClientOne(name);
    }

    @GetMapping(value ="/hi2" )
    public String sayHi2(){

        StringBuilder sb = new StringBuilder();

        sb.append(schedualServiceHi.sayHiFromClientOne("hzqiuxm")).append("\n");
        sb.append(schedualServiceHi.sayHiFromClientOne("hzqiuxm002",30)).append("\n");
        sb.append(schedualServiceHi.sayHiFromClientOne(new User("hzqixm003",30))).append("\n");

        return sb.toString();
    }
}

服务层:
@FeignClient(value = "service-hi", fallback = SchedualServiceHiHystric.class)
public interface SchedualServiceHi {

    @RequestMapping(value = "/hi",method = RequestMethod.GET)
    String sayHiFromClientOne(@RequestParam(value = "name") String name);

    @GetMapping(value = "/hibyAge")
    String sayHiFromClientOne(@RequestHeader("name") String name,@RequestHeader("age") Integer age);

    @PostMapping(value = "/hibyUser")
    String sayHiFromClientOne(@RequestBody User user);

}

降级服务:
 */
@Component
public class SchedualServiceHiHystric implements SchedualServiceHi {
    @Override
    public String sayHiFromClientOne(String name) {
        return "sorry " + name;
    }

    @Override
    public String sayHiFromClientOne(String name, Integer age) {
        return "sorry " + name + "your age is " + age;
    }

    @Override
    public String sayHiFromClientOne(User user) {
        return "sorry " + user.getName() + "your age is " + user.getAge();
    }

}

同样,在这个示例里,我们也需要一个自定义的User对象。

特别要注意的是在服务层(其实就是个接口),我们使用了直接@FeignClient来表示声明式调用调用,编译的时候,编译器根据这个注解生成一个Feign客户端,同时也创建了一个Ribbon客户端。在此接口中声明的方法,绑定参数的时候一定要指明具体的参数名,不像普通的SpringMVC中,不会自动匹配。

  • 测试结果:输入url,localhost/hi2,结果返回
hi hzqiuxm, i am from port8762
User{name='hzqiuxm002', age=30}, i am from port8763
hi, hzqixm003, 30, i am from port8762
sorry hzqiuxm
User{name='hzqiuxm002', age=30}, i am from port8763
hi, hzqixm003, 30, i am from port8762

继承特性

通过上面参数绑定的例子,相信大家都发现了一个问题:虽然Feign的声明式调用,只需要我们写一个接口,声明调用的方法并用注解指定服务的名称即可。但我们几乎可以完全从服务提供方的控制层中依靠复制代码,构建出相应的服务客户端绑定接口。

一些聪明的同学在就想到了利用继承的特性来解决上面这个问题,减少编码量。

这里就不做具体的演示了,因为实现思想很简单:定义一个共同的接口,服务提供者控制层实现它,那么控制层就不用再定义请求映射注解@RequestMapping了,而参数注解咋重写时也会自动带过来。然后Feign的服务接口中,继承该共同的接口,就不用重复申明方法了。

上面的这种操作其实有有好处也有坏处,大家要根据情况适当的选择使用。

优点: 可以从接口的定义从控制层剥离,同时打包成jar轻易实现共享,有效减少服务客户端的绑定配置

缺点:在接口构建期间就建立了依赖,接口变动会对项目构建造成影响。如果服务提供方修改了一个接口定义,那么会直接导致客户端工程的构建失败。可谓牵一发而动全身,前后版兼容上要严格遵守开闭原则,增加不必要的维护工作量。

个人是不建议使用继承来简化部分代码的,觉得站在微服务的角度看,缺点是大于优点的。那有没有更加优雅的解决方案呢?当然有,关于这点我将在系列课程的后面讲到。

核心配置

Ribbon配置

  • 全局配置
    直接使用ribbon.=的方式来设置ribbon的各项默认参数
ribbon.ConnectTimeout=500
ribbon.ReadTimeout=5000
  • 指定服务配置
    实际情况中,各种服务调用的超时时间会有所不同,统一的全局配置可能不能满足业务的要求,所以我们要采用指定服务配置的方式进行配置。配置的格式为:
    .Ribbon.key=value ,其中的就是@FeignClient的value属性对应值,就是服务名。
SERVICE-HI.ribbon.ConnectTimeout=300
SERVICE-HI.ribbon.ReadTimeout=2000
SERVICE-HI.ribbon.OkToRetryOnAllOperations=true
SERVICE-HI.ribbon.MaxAutoRetriesNextServer=2
SERVICE-HI.ribbon.MaxAutoRetries=l

配置的时候要注意:必须让Hystrix的超时时间大于Ribbon或Feign的超时时间

Hystrix配置

  • 全局配置
    类似Ribbon的全局配置,采用hystrix.command.default前缀直接配置即可
hystrix.command.default.execution.isolation.thread.TimeoutinMilliseconds=5000
  • 禁用Hystix
    我们可以通过配置:feign.hystrix.enabled=false 全局关闭掉Hystrix的功能,但是这样显然不灵活,我们一般只想对某个服务客户端关闭。这个时候我们就要通过@Scope("prototype")注解为制定的客户端配置Feign.Builder实例

第一步,构建一个关闭的配置类

public class DisableHystrixConfiguration {

    @Bean
    @Scope("prototype")
    public Feign.Builder feignBuilder(){

        return Feign.builder();
    }

}

第二步,将配置类添加到@FeignClient的value属性上

  • 指定命令配置
    我们还可能针对实际业务情况制定出不同的配置方案,我们可以采用hystrix.command.作为前缀进行配置。
    默认情况下会采用Feign客户端中的方法名作为标识,比如:
@RequestMapping(value = "/hi",method = RequestMethod.GET)
String sayHiFromClientOne(@RequestParam(value = "name") String name);
针对上面方法配置如下:
hystrix.command.hi.execution.isolation.thread.TimeoutinMilliseconds=5000

方法可能存在重载,所以要合理规划好这种配置,别弄出莫名其妙的问题自己还不知道原因呢。

  • 服务降级配置
    这个在简单示例中已经演示了,和Ribon的不同就是采用了单独的类,实现了声明式接口。

其他配置

  • 请求压缩:支持对请求和响应进行GZIP压缩,减少通信请求过程中性能损耗
feign.compression.request.enabled=true
feign.compression.response.enabled=true

我们还可以对请求压缩做一些更加详细的配置:指定压缩的请求数据类型,设置请求压缩的大小下限等,可以在FeignClientEncodingProperties类中查看其具体默认值和配置属性。

  • 日志配置
    springboot中对所有的日志输出使用了java logging作为门面来统一管理,不管你用使用的是何种日志(log4j,logback,log4j2),统一采用logging.level来控制(类似使用slf4j来统一管理日志实现一样)。
    如果我们要查看Feign调用细节,需要做二步:

  • 第一步,开启你要查看的日志

logging.level.com.hzqiuxm.web.xxx=DEBUG
  • 第二步,将Feign客户端默认的日志级别NONE进行重新设置(可以在启动类或配置类中设置)
import feign.Logger;
@Bean
Logger.Level feignLoggerLevel(){

   return Logger.Level.FULL;
}

如果你是采用配置类的形式,记得要在声明式接口的注解@FeignClient的Configuration中指定该配置类。

SpringCloud微服务系列(4)

hzqiuxm阅读(276)评论(0)

服务容错保护

基本介绍

为什么要服务容错保护

我们现在已经了解,微服务架构中,系统是分成好多个服务单元的,各个但隐患之间通过注册中心建立联系。
服务多了,出问题的概率同样也就增大了,问题可能来自依赖的服务也可能来自网络。不管如何肯定会导致服务调用故障或者延迟,而这些问题会直接导致依赖调用方的服务也出现问题。
这样一层影响一层,再随着请求的增加,任务的积压,最终可能导致服务瘫痪,触发雪崩。

举个例子,比如下图所示的一个电子合同服务调用:

上图调用关系非常简单,假设在用户服务调用签署服务的时候需要调用计费服务来判断当前用户资源是否允许操作时,计费服务因自身处理逻辑等原因造成了响应缓慢,所以签署服务的线程将被挂起,以等待计费服务的响应,在漫长的等待后用户服务会被告知调用签署服务失败。如果是在高并发的场景下,挂起的线程很多,使得后来的签署服务请求都被阻塞,最终导致签署服务无法使用。这样还没结束,如果签署服务无法使用,那么依赖它的合同服务和用户服务也会出现线程挂起,到阻塞到服务不可用,然后继续向外围蔓延。
你看,本来计费服务的问题,最后导致原来正常的签署服务,合同服务,用户服务都不可用,就像雪山上出现雪崩一样,连锁反应。

为了解决这个问题,微服务架构中引入了断路器模式来进行对服务容错保护。其实很像我们家里的断路器,如果没有断路器,电流过载了(例如功率过大、短路等),电路不断开,电路就会升温,甚至是烧断电路、起火。有了断路器之后,当电流过载时,会自动切断电路(跳闸或保险丝熔断),从而保护了整条电路与家庭的安全。当电流过载的问题被解决后,只要将关闭断路器,电路就又可以工作了。

Hystrix介绍

针对上述问题,SpringCloud就采用了Spring Cloud Hysrix 来实现了断路器、线程隔离等一系列保护功能。它也是基于Netflix的开源框架Hystrix来实现的,该框架的目标就是通过控制哪些访问远程系统的节点,从而对延迟或故障提供强大的容错能力。

Hystrix包含的功能有:服务降级、服务熔断、线程和信号隔离、请求缓存、请求合并以及服务监控等。
这里有个注意点,平时我发现很多人把Hystrix称之为断路器,其实是不准确的,Hystrix包含的远不只是一个断路器,按我的理解Hystrix包含了:断路器、命令对象、线程池、请求队列、信号量、健康检查对象等等组件。不然光光只是断路器,可实现不了上述我们介绍的那些功能。

简单示例

我们对上一章中的Ribbon服务进行改造,让其具备容错保护功能。

  • 第一步:加入依赖
compile('org.springframework.cloud:spring-cloud-starter-hystrix')
  • 第二步:加入注解,启动容错保护
@EnableCircuitBreaker //开启断路器容错保护
@EnableDiscoveryClient
@SpringBootApplication
public class ServiceRibbonApplication {

   public static void main(String[] args) {
      SpringApplication.run(ServiceRibbonApplication.class, args);
   }


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

注意,我们其实可以用注解@SpringCloudApplication来替代上面的3个注解

  • 第三步:在调用服务方法上加上注解,指定回调方法
/**
 * 负载方法
 * @param name
 * @return
 * SERVICE-HI :虚拟主机名
 * restTemplate+Ribbon 可以应对简单的URL 如果url复杂,使用Feign,它整合了Ribbon
 */

@HystrixCommand(fallbackMethod = "hiFallback")
public String hiService(String name) {
    return restTemplate.getForObject("http://SERVICE-HI/hi?name="+name,String.class);
}

  • 第四步:新增指定的方法,完成降级逻辑
/**
 * 降级服务处理
 * 加入Hysrix使用回调方法,使用注解方式时必须和调用服务在一个类中
 * @return
 */
public String hiFallback(String name){//注意这里的参数要和上面的一样,否则会找不到该方法

    return "hi service has a error!";
}

我们启动测试下,架构如下:

访问测试:localhost:8764/hi?name=hzqiuxm

结果会随机出现成功和失败的情况

hi hzqiuxm, i am from port8763
hi service has a error!hzqiuxm
hi hzqiuxm, i am from port8763
hi hzqiuxm, i am from port8762

从上述结果可以看出,断路器已经设置成功了

核心

Hystrix原理分析

工作流程
  • 1、创建HystrixCommand或HystrixObservableCommand对象,以“命令”方式实现对服务调用操作封装
  • 2、执行命令
  • 3、是否被缓存,是的话缓存结果会立即返回
  • 4、断路器是否打开,打开的话立即返回
  • 5、线程池、请求队列、信号量资源判断,不够时执行第8步
  • 6、请求依赖的服务
  • 7、计算断路器的健康度,根据成功、失败、拒绝、超时等信息计算是否打开/闭合断路器
  • 8、如果需要进行,fallback降级处理
  • 9、返回成功响应

断路器原理

断路器在HystrixCommand或HystrixObservableCommand执行时,起到了关键作用,它是Hystrix的核心部件。那么它是怎么决策和计算健康度的呢?
我们先看看断路器HystrixCircuitBreaker接口中定义的主要方法和类:

boolean allowRequest(); //每个Hystrix命令的请求通过它判断是否被执行
boolean isOpen(); //判断断路器的开发/关闭状态
void markSuccess(); //从半开状态到关闭
void markNonSuccess(); //从半开状态到打开
boolean attemptExecution(); //获取断路器状态,是非幂等的

class Factory{...} //维护Hystrix和断路器的关系集合

class HystrixCircuitBreakerImpl implements HystrixCircuitBreaker{...} //接口的一个实现类

这里面我们主要关注下HystrixCircuitBreakerImpl 中计算断路器打开的规则,规则在subscribeToStream()方法中:

public void onNext(HealthCounts hc) {
    // check if we are past the statisticalWindowVolumeThreshold
    if (hc.getTotalRequests() < properties.circuitBreakerRequestVolumeThreshold().get()) { 
        // we are not past the minimum volume threshold for the stat window,
        // so no change to circuit status.
        // if it was CLOSED, it stays CLOSED
        // if it was half-open, we need to wait for a successful command execution
        // if it was open, we need to wait for sleep window to elapse
    } else {
        if (hc.getErrorPercentage() < properties.circuitBreakerErrorThresholdPercentage().get()) {
            //we are not past the minimum error threshold for the stat window,
            // so no change to circuit status.
            // if it was CLOSED, it stays CLOSED
            // if it was half-open, we need to wait for a successful command execution
            // if it was open, we need to wait for sleep window to elapse
        } else {
            // our failure rate is too high, we need to set the state to OPEN
            if (status.compareAndSet(Status.CLOSED, Status.OPEN)) {
                circuitOpened.set(System.currentTimeMillis());
            }
        }

从上面代码逻辑中我们可以看到,当最大线程数超过阈值并且请求错误百分比也超过阈值的时候,断路器会通过CAS多线程保护的方式打开。

circuitBreakerRequestVolumeThreshold决定了最大线程数阈值,它的默认值由default_circuitBreakerRequestVolumeThreshold = 20决定。
circuitBreakerErrorThresholdPercentage决定了请求错误百分比阈值,它的默认值由default_circuitBreakerErrorThresholdPercentage = 50决定。
(默认值可以参考HystrixCommandProperties类中的定义)

下图是断路器的状态关系图:

当断路器处于打开状态时,如果打开时间超过了我们定义的circuitBreakerSleepWindowInMilliseconds时间(默认5000毫秒),那么断路器会切换到半开状态。
如果此时请求继续失败,断路器又变回成打开状态,等待下个circuitBreakerSleepWindowInMilliseconds时间。若请求成功,则断路器变为闭合状态。

最后附上Hystrix官方文档中断路器详细执行逻辑,大家可以在仔细理解下。

实战详解

首先大家要清楚,Hystrix对于依赖服务调用采用了依赖隔离的方式,隔离方式主要有线程隔离和信号量隔离。

  • 线程隔离:为每一个依赖服务创建一个独立的线程,性能上低于信号量隔离(我还是推荐使用这个,除非你的应用无法忍受9ms级别的延迟)。

  • 信号量隔离:用信号量控制单个依赖服务,开销远小于线程隔离,但是无法异步和设置超时。

创建请求命令

我们这边只介绍以注解的方式来创建请求命令,除了按照注解的方式,还可以以继承HystrixCommand或HystrixObservableCommand类的方式,有兴趣的同学可以参考官方文档。
创建请求命令,按照调动方式,我们可以分为三种:

  • 同步方式:最普通最常见的方式
@HystrixCommand(fallbackMethod = "hiFallback")
public String hiService(String name) {
    return restTemplate.getForObject("http://SERVICE-HI/hi?name="+name,String.class);
}
  • 异步方式:
@HystrixCommand
public Future<String> hiServiceAsync(final String name ){

    return new AsyncResult<String>(name){

        @Override
        public String get() throws ExecutionException {
            return restTemplate.getForObject("http://SERVICE-HI/hi?name="+name,String.class);
        }
    };
}

  • 响应式方式:
public Observable<String> hiServiceObs(final String name){

    return Observable.create(new Observable.OnSubscribe<String>() {
        @Override
        public void call(Subscriber<? super String> subscriber) {

            if(!subscriber.isUnsubscribed()){

                String forObject = restTemplate.getForObject("http://SERVICE-HI/hi?name=" + name, String.class);

                subscriber.onNext(forObject);
                subscriber.onCompleted();
            }
        }
    });
}
定义服务降级

从上面可以看出,当Hystrix命令执行失败,fallback是实现服务降级处理的后备方法。使用相对比较简单,只要在HystrixCommand注解的fallbackMethod属性指定对应的方法就行。
唯一需要注意的就是一点:fallbackMethod属性指定的方法必须定义在同一个类中,并且参数保持一致。

并不是所有的服务都要去实现降级逻辑,比如一些写操作的命令,批处理的命令,离线计算的命令等。不论Hystrix命令是否实现了服务降级,命令的状态和断路器状态都会更新。

异常处理
  • 异常忽略:对某个异常,不调用fallback操作,而是抛出。实现例子如下:
@HystrixCommand(ignoreExceptions = {需要忽略的异常类.class})
  • 异常分类降级:根据不同异常,采用不同的降级处理。
    实现也很简单,只要在fallbackMethod的实现方法参数上增加Throwable对象,这样在方法内部就可以获取触发服务降级的具体异常内容了。比如:
User fallbackl{String id, Throwable e){  获取e的异常类型,采取对应的降级处理 ... }
请求缓存

微服务架构的目的之一就是应对不断增长的业务,随着每个微服务需要承受的并发压力也越来越大。都说要提高系统性能,可以采用缓存技术。那么Hystrix当仁不让的提供了。

我们可以通过注解的方式,简单的实现请求缓存。请求缓存注解有:

  • @CacheResult:标记请命令返回的结果应该被缓存,必须和@HystrixCommand注解结合使用,所用属性有:cacheKeyMethod
@CacheResult(cacheKeyMethod = "getNameByidCacheKey")
@HystrixCommand(fallbackMethod = "hiFallback")
public String hiService(String name) {
    return restTemplate.getForObject("http://SERVICE-HI/hi?name="+name,String.class);
}

private Long getNameByidCacheKey(Long id) {
    return id;
}

上述代码中@CacheResult代表开启缓存功能,当调用结果返回后将被Hystrix缓存,缓存的Key值不指定时就会使用该方法中的所有参数(name),当然我们可以自定义缓存Key的生成规则,上面就使用了cacheKeyMethod指定了具体的生成函数。

  • @CacheRemove:标记请求命令的缓存失效,失效的缓存根据定义的Key决定,常用属性:command、cacheKeyMethod
@CacheResult
@HystrixCommand(fallbackMethod = "hiFallback")
public String hiService(@CacheKey("name") String name) {
    return restTemplate.getForObject("http://SERVICE-HI/hi?name="+name,String.class);
}

@CacheRemove(commandKey = "hiService")
public void update(@CacheKey("name")User user){

    restTemplate.getForObject("http://USER-SERVICE/users",user,User.class);

}

注意,commandKey属性是必须要指定的,它用来指明需要使用请求缓存的请求命令。

  • @CacheKey:标记在请求命令的参数上,使其作为缓存的Key值。如果没有标注,则会使用所有参数。常用属性:value
@CacheResult
@HystrixCommand(fallbackMethod = "hiFallback")
public String hiService(@CacheKey("name") String name) {
    return restTemplate.getForObject("http://SERVICE-HI/hi?name="+name,String.class);
}

注意它的优先级最低,使用了cacheKeyMethod的话,它就不生效了。

请求合并

把一个单体应用拆分为微服务应用后,最明显的变化就是增加了通信消耗和连接数占用。在高并发的情况下,随着通信次数的增加,总的通信时间消耗将会变得不理想。

Hystrix也考虑到了这个问题,提供了HystrixCollapser来实现请求的合并,以减少通信消耗和线程数的占用。

HystrixCollapser实现的基本思想就是在HystrixCommand之前放置一个合并处理器,将处于很短时间内(默认10ms)对同一依赖服务的多个请求进行整合并以批量方式发起请求。批量的发起请求需要开发人员自己实现,并且服务的提供方也要提供相应的批量实现接口才行。

可以通过下面的图来直观了解下:

实现步骤简单如下:

  • 1.调用方需要准备2个方法,一个单个调用,一个合并调用
  • 2.在单个调用的方法上,加一个合并器

注意,请求合并会有额外的开销,因为合并的时候会有个延迟时间10ms。一般在高并发的时候才会启用,所以需要考虑请求命令本身的延迟和延迟时间窗内的并发量来统筹考虑。

配置属性

属性配置优先级

4种属性配置的优先级,由低到高分别是:
- 全局默认值:其他三个都没设时
- 全局配置属性:通过配置文件定义,可以配合动态刷新在运行期动态调整
- 实例默认值:通过代码为实例定义默认值
- 实例配置属性:通过配置文件定义,可以配合动态刷新在运行期动态调整

Command属性

Command属性主要用来控制HystrixCommand命令的行为。
- execution配置,主要功能是实现隔离,超时

execution.isolation.strategy 设置执行隔离策略,有两个值:THREAD和SEMAPHORE。

THREAD: 通过线程池隔离的策略。它在独立的线程上执行, 并且它的并发限制
受线程池中线程数量的限制。
SEMAPHORE: 通过信号量隔离的策略。它在调用线程上执行, 并且它的并发限
制受信号量计数的限制。

execution.isolation.thread.timeoutinMilliseconds :配置HystrixCommand执行的超时时间,单位为毫秒。
当HystrixCommand执行时间超过该配置值之后,Hystrix会将该执行命令标记为TIMEOUT并进入服务降级处理逻辑。

execution.timeout.enabled: 该属性用来配置HystrixCommand.run()的执行是否启用超时时间,默认为true。

execution.isolation.thread.interruptOnTimeout: 该属性用来配置当HystrixCommand.run()执行超时的时候是否要将它中断。

execution.isolation. thread.interruptOnCancel: 该属性用来配置当HystrixCommand.run()执行被取消的时候是否要将它中断。

execution.isolation.semaphore.maxConcurrentRequests: 当HystrixCommand的隔离策略使用信号量的时候,该属性用来配置信号量的大小(并发请求数)

  • fallback属性配置,主要实现并发数控制和是否降级

fallback.isolation.semaphore.maxConcurrentRequests: 该属性用来设置从调用线程允许HystrixComrnand.getFallback()方法执行的最大并发请求数。

fallback.enabled: 该属性用来设置服务降级策略是否启用

  • circuitBreaker配置,主要实现休眠时间,断路器打开条件(请求并发数,错误百分比),强制(开关)

circuitBreaker.enabled: 该属性用来确定当服务请求命令失败时,是否使用断路器来跟踪其健康指标和熔断请求,默认是true。

circuitBreaker.requestVolumeThreshold: 该属性用来设置在滚动时间窗中,断路器熔断的最小请求数,默认20。

circuitBreaker.sleepWindowinMilliseconds: 该属性用来设置当断路器打开之后的休眠时间窗,默认5000毫秒。

circuitBreaker.errorThresholdPercentage: 该属性用来设置断路器打开的错误百分比条件,默认50%。

circuitBreaker.forceOpen: 如果将该属性设置为true, 断路器将强制进入“ 打开”状态,它会拒绝所有请求,默认false。

circuitBreaker.forceClosed: 如果将该属性设置为true,断路器将强制进入“关闭”状态,它会接收所有请求,默认false。

  • metrics配置,主要实现滚动时间窗相关配置

metrics.rollingStats.timeinMillseconds: 该属性用来设置滚动时间窗的长度,单位为毫秒,默认10000。

metrics.rollingstats.numBuckets: 该属性用来设置滚动时间窗统计指标信息时划分“桶”的数量,默认10。

metrics.rollingPercentile.enabled: 该属性用来设置对命令执行的延迟是否使用百分位数来跟踪和计算,默认true。

metrics.rollingPercentile.timeinMilliseconds: 该属性用来设置百分位统计的滚动窗口的持续时间,单位为毫秒,默认60000。
注意:该值通过动态刷新不会有效。

metics.rollingPercentile.numBuckets: 该属性用来设置百分位统计滚动窗口中使用“ 桶”的数量,默认6。
注意:该值通过动态刷新不会有效。

metrics.rollingPercentile.bucketSize: 该属性用来设置在执行过程中每个“桶”中保留的最大执行次数,默认100。
注意:该值通过动态刷新不会有效。

metrics.healthSnapshot.intervalinMilliseconds: 该属性用来设置采集影响断路器状态的健康快照(请求的成功、错误百分比)的间隔等待时间,默认500。
注意:该值通过动态刷新不会有效。

  • requestContext配置,主要实现缓存和日志相关

requestCache.enabled: 此属性用来配置是否开启请求缓存,默认true。

requestLog.enabled: 该属性用来设置Hys立ixCommand的执行和事件是否打印日志到HystrixRequestLog中,默认true。

collapser属性

合并请求相关设置
maxRequestsinBatch: 该参数用来设置一次请求合并批处理中允许的最大请求数,默认Integer.MAX VALUE。

timerDelayinMillseconds: 该参数用来设置批处理过程中每个命令延迟的时间,单位为毫秒,默认10。

requestCache.enabled: 该参数用来设置批处理过程中是否开启请求缓存,默认true。

threadPool属性

线程池的先关配置
coreSize: 该参数用来设置执行命令线程池的核心线程数,该值也就是命令执行的最大并发量,默认10。

maxQueueSize: 该参数用来设置线程池的最大队列大小,默认-1。
当设置为-1时,线程池将使用SynchronousQueue实现的队列,否则将使用LinkedBlockingQueue实现的队列。
注意:该值通过动态刷新不会有效。

queueSizeRejectionThreshold: 该参数用来为队列设置拒绝阈值,默认5。
注意:当maxQueueSize属性为-1 的时候,该属性不会生效。

metrics.rollingstats.timeinMilliseconds: 该参数用来设置滚动时间窗的长度,单位为毫秒,默认10000。

metrics.rollingStats.numBuckets: 该参数用来设置滚动时间窗被划分成“桶”的数量,默认10。

上面属性对应全局默认值,全局配置属性,实例默认值,实例配置属性值请在Hystrix的番外篇中查阅。

参考资料推荐

https://baijiahao.baidu.com/s?id=1593211109840459044&wfr=spider&for=pc
https://www.ebayinc.com/stories/blogs/tech/application-resiliency-using-netflix-hystrix/
https://github.com/Netflix/Hystrix/wiki
https://github.com/Netflix/Hystrix/wiki/How-it-Works

SpringCloud微服务系列(3)

hzqiuxm阅读(274)评论(0)

客户端负载Ribbon

Ribbon的基本介绍

什么是客户端负载

我们在做服务集群的时候,经常会听到负载均衡这个词,比如下图的一个架构:

客户端在访问服务器的时候,中间一般会用一些硬件(F5)或软件(nginx)来作负载均衡,从而实现后端各服务器分摊请求压力,达到均衡的目的。图中的nginx就是负载均衡器,我们通常称之为服务端的负载均衡,客户端不用关心自己调用的是哪个服务,只要统一访问某一个地址,负载均衡器会根据某个负载策略(权重,可用性,线性轮询等)路由到某个具体的服务器上。

那么客户端负载均衡和服务端负载均衡有什么区别呢?如果我们把上图架构改造成下图所示:

我们在客户端后面加了一个服务器清单(里面维护着后端可以用的服务),客户端访问后端服务的时候,自己去选择一个服务去访问。所以它们二者最大的不用点就是谁来维护服务端清单,就称之为谁的负载均衡。

SpringCloud中的Ribbon

Spring Cloud Ribbon是一个基于HTTP和TCP的客户端负载工具,它基于Netflix实现。它不用像注册中心、配置中心、API网关那样独立部署,而且几乎存在与于每一个SpringCloud构建的微服务和基础设施中。我们要使用它也很简单,只要添加spring-cloud-starter-ribbon的依赖即可。

简单示例

在上一节注册中心的讲解中我们没有使用客户端的负载均衡,服务消费者调用服务生产者的架构如下所示:

消费者调用的时候,输入的URL是指向具体的生产者的,接下来我们新建一个项目service-ribbon,然后把Ribbon加进去。
新建的项目添加依赖:spring-cloud-starter-ribbon。

通过Spring Cloud Ribbon来使用客户端负载均衡调用,需要二个步骤:
- 第一步,服务提供者的多个实例注册到注册中心
- 第二步,服务消费者通过调用被@LoadBalanced注解修饰过的RestTemplate来实现

第一步的话,只要仿造上节的启动注册中心(单中心和多中心都可以)和生产者多个实例即可。
第二步的话,需要编写一些代码,我们会有3个类:
ServiceRibbonApplication,启动类并负责生成RestTemplate实例

@EnableDiscoveryClient
@SpringBootApplication
public class ServiceRibbonApplication {

   public static void main(String[] args) {
      SpringApplication.run(ServiceRibbonApplication.class, args);
   }


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

HelloControler,提供一个请求入口,目的是调用本身的服务

@RestController
public class HelloControler {

    @Autowired
    HelloService helloService;
    @RequestMapping(value = "/hi")
    public String hi(@RequestParam String name){
        return helloService.hiService(name);
    }
}

HelloService,提供给HelloControler调动,服务本身不提供具体的服务,而是去调用服务生产者的服务

@Service
public class HelloService {

    @Autowired
    RestTemplate restTemplate;

    /**
     * 负载方法
     * @param name
     * @return
     * SERVICE-HI :虚拟主机名
     */
    public String hiService(String name) {
        return restTemplate.getForObject("http://SERVICE-HI/hi?name="+name,String.class);
    }
}

配置文件如下:

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/
server:
  port: 8764
spring:
  application:
    name: service-ribbon

启动service-ribbon,eureka-server,eureka-client(2个实例),在注册中心看到注册的服务如下:

通过url:http://localhost:8764?name=hziquxm 来访问下服务, 多次访问后发现以下返回结果轮询出现:

hi hzqiuxm, i am from port8762
hi hzqiuxm, i am from port8763

证明ribbon已经发挥了作用,默认采用了轮询访问后台服务的方式。
最终我们的架构变成如下所示:

Ribbon深入详解

RestTemplate

可以从简单示例得知RestTemplate的作用非常关键,该对象会使用Ribbon的自动化配置,通过注解@LoadBalanced开启客户端负载。从名字上我们得知RestTemplate和REST请求是很有关系的,它就是针对REST几种不同请求类型调用实现工具类。我们接下来就看看这个工具类的增删改查。

GET请求

RestTemplate中对GET请求,通过如下两个方法进行调用实现:
第一种getForEntity(),它有三种不同的重载实现

<T> ResponseEntity<T> getForEntity(URI url, Class<T> responseType)

url:请求的地址,注意类型是是URL(包含了路径和参数等信息),不是String
responseType:请求响应体包装类型
ResponseEntity:返回结构,是Spring对HTTP请求响应的封装,该对象中body中内容类型会根据第二个参数类型进行转换,比如第二个参数是String.class代表返回对象body的内容会转换为String类型

<T> ResponseEntity<T> getForEntity(String url, Class<T> responseType, Object... uriVariables)
  • url:字符串类型的url,通常这种用的比较多
  • uriVariables:url中的参数绑定,数组方式,顺序和url占位符按顺序对应
<T> ResponseEntity<T> getForEntity(String url, Class<T> responseType, Map<String, ?> uriVariables)
  • uriVariables:url中的参数绑定,Map类型,需要提供一个key,value的map作为参数

第二种getForObject(),功能类似对getForEntity()进行进一步封装,它通过HttpMessageConverterExtractor对请求响应体body中的内容进行对象转换,实现了请求直接返回包装好的对象内容,不用再去body中去取了。它也提供三种不同的重载实现:

<T> T getForObject(URI url, Class<T> responseType)
<T> T getForObject(String url, Class<T> responseType, Object... uriVariables)
<T> T getForObject(String url, Class<T> responseType, Map<String, ?> uriVariables)

参数和 getForEntity类似,就不赘述了。

POST请求

RestTemplate中对POST请求,通过如下三个方法进行调用实现:
第一种,postForEntity(),该方法和GET中的getForEntity类型,返回值也是一个ResponseEntity对象,它有三种不同重载方法:

<T> ResponseEntity<T> postForEntity(URI url, Object request, Class<T> responseType)
<T> ResponseEntity<T> postForEntity(String url, Object request, Class<T> responseType, Map<String, ?> uriVariables)
<T> ResponseEntity<T> postForEntity(String url, Object request, Class<T> responseType, Object... uriVariables)
  • request:可以是普通类型(比如自定义的某个实体Bean)或者是HttpEntity类型,普通类型不包含header,HttpEntity类型包含header。

第二种,postForObject(),也和getForObject()有些类似,包含了三种重载方法:

<T> T postForObject(URI url, Object request, Class<T> responseType)
<T> T postForObject(String url, Object request, Class<T> responseType, Object... uriVariables)
<T> T postForObject(String url, Object request, Class<T> responseType, Map<String, ?> uriVariables)

除了返回类型,参数都和postForEntity()一样,就不赘述了。

第三种,postForLocation(),该方法实现了以POST请求提交资源,并返回新资源URI,该URI就相同于指定了返回类型。
它也有三种不同的重载方法:

URI postForLocation(URI url, Object request)
URI postForLocation(String url, Object request, Object... uriVariables)
URI postForLocation(String url, Object request, Map<String, ?> uriVariables)

参数都和前面二种一样,就不赘述了。

PUT请求

RestTemplate中对PUT请求,就是通过put方法调用实现的,它有三种不同的重载方法:

void put(URI url, Object request)
void put(String url, Object request, Object... uriVariables)
void put(String url, Object request, Map<String, ?> uriVariables)

请求参数之前GET或POST中都有出现,就不赘述了。

PATCH请求

RestTemplate中对PATCH请求,是通过patchForObject方法调用实现的,它有三种不同的重载方法:

<T> T patchForObject(URI url, Object request, Class<T> responseType)
<T> T patchForObject(String url, Object request, Class<T> responseType,Object... uriVariables)
<T> T patchForObject(String url, Object request, Class<T> responseType,Map<String, ?> uriVariables)

请求参数之前GET或POST中都有出现,就不赘述了。

DELETE请求

RestTemplate中对DELETE请求,就是通过delete方法调用实现的,它有三种不同的重载方法:

void delete(URI url)
void delete(String url, Object... uriVariables)
void delete(String url, Map<String, ?> uriVariables)

方法参数非常简单,一般我们会把请求唯一标示拼接在url中。

源码分析

熟悉Spring的同学肯定知道RestTemplate是Spring自己提供的,那么和客户端负载均衡有关的,貌似就剩下之前没有见过的注解@LoadBalancer了,接下来我们就从它开始,看看Ribbon到底是怎么实现客户端负载均衡的。

首先我们会情不自禁的看下@LoadBalancerClinet注解的源码:

/**
 * Annotation to mark a RestTemplate bean to be configured to use a LoadBalancerClient
 * @author Spencer Gibb
 */
@Target({ ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Qualifier
public @interface LoadBalanced {
}

看完后好像并没有发生什么,就是一个自定义的注解,怎么办?别慌来读一遍上面的官方注释:该注解是用来标记RestTemplate,使其使用LoadBalancerClient(负载均衡的客户端)来配置它。看来LoadBalancerClient有蹊跷,赶紧进去看下。

public interface LoadBalancerClient extends ServiceInstanceChooser {
    <T> T execute(String var1, LoadBalancerRequest<T> var2) throws IOException;

    <T> T execute(String var1, ServiceInstance var2, LoadBalancerRequest<T> var3) throws IOException;

    URI reconstructURI(ServiceInstance var1, URI var2);
}

顺便也看下它继承的接口ServiceInstanceChooser

public interface ServiceInstanceChooser {

    /**
     * Choose a ServiceInstance from the LoadBalancer for the specified service
     * @param serviceId the service id to look up the LoadBalancer
     * @return a ServiceInstance that matches the serviceId
     */
    ServiceInstance choose(String serviceId);
}
  • choose()方法,根据传入的服务名serviceId,从负载均衡器挑选一个对应的服务实例
  • execute()方法,2个重载方法都是使用从负载均衡器中挑选出来服务实例来执行请求的内容
  • reconstructURI(),把服务名称的URI(后一个参数),转换成host+port形式的请求地址(前一个参数)

我们按照习惯,以LoadBalancerClient为引线,整理下和它有关的类图如下:

类图中最关键的类就是LoadBalancerInterceptor,它是由LoadBalancerAutoConfiguration自动化配置类生成的。
它的作用就是对加了@LoadBalanced注解修饰的RestTemplate对象向外发起HTTP请求时拦截客户端的请求时进行拦截,获取需要的真正实例,发起实际请求。
它的源码主要部分如下:

@Override
public ClientHttpResponse intercept(final HttpRequest request, final byte[] body,
      final ClientHttpRequestExecution execution) throws IOException {
   final URI originalUri = request.getURI();
   String serviceName = originalUri.getHost();
   Assert.state(serviceName != null, "Request URI does not contain a valid hostname: " + originalUri);
   return this.loadBalancer.execute(serviceName, requestFactory.createRequest(request, body, execution));
}

intercept函数负责拦截客户端请求,然后通过LoadBalancerClient的execute方法获取具体实例发起实际请求。
由上面类图得知LoadBalancerClient是一个接口,我们要看下它的实现类RibbonLoadBalancerClient的execute方法寻找关键。

@Override
public <T> T execute(String serviceId, LoadBalancerRequest<T> request) throws IOException {
   ILoadBalancer loadBalancer = getLoadBalancer(serviceId);
   Server server = getServer(loadBalancer);
   if (server == null) {
      throw new IllegalStateException("No instances available for " + serviceId);
   }
   RibbonServer ribbonServer = new RibbonServer(serviceId, server, isSecure(server,
         serviceId), serverIntrospector(serviceId).getMetadata(server));

   return execute(serviceId, ribbonServer, request);
}
protected Server getServer(ILoadBalancer loadBalancer) {
   if (loadBalancer == null) {
      return null;
   }
   return loadBalancer.chooseServer("default"); // TODO: better handling of key
}

从RibbonLoadBalancerClient的源码中,我们可以发现execute方法一开就就通过getServer()方法,根据传入的服务名来获取具体的服务实例,而获取实例又是依赖ILoadBalancer对象的chooseServer方法。
在获取了服务实例之后,会将server对象包装成一个RibbonServer对象,该对象额外增加了是否使用HTTPS协议,服务名等其它信息。然后使用该对象回调LoadBalancerRequest的apply方法,向一个实际的服务实例发起请求,从而实现以服务名为host的URI请求到host:port形式的实际地址转换。

@Override
public <T> T execute(String serviceId, ServiceInstance serviceInstance, LoadBalancerRequest<T> request) throws IOException {
   Server server = null;
   if(serviceInstance instanceof RibbonServer) {
      server = ((RibbonServer)serviceInstance).getServer();
   }
   if (server == null) {
      throw new IllegalStateException("No instances available for " + serviceId);
   }

   RibbonLoadBalancerContext context = this.clientFactory
         .getLoadBalancerContext(serviceId);
   RibbonStatsRecorder statsRecorder = new RibbonStatsRecorder(context, server);

   try {
      T returnVal = request.apply(serviceInstance);
      statsRecorder.recordStats(returnVal);
      return returnVal;
   }
... ...

再简单看下ILoadBalancer接口,它其实是我们下面要介绍的一系列各种负载均衡器的接口,我们先简单看下接口中定义的一些抽象操作,具体的在负载均衡器中进行讲解。

public interface ILoadBalancer {

   public void addServers(List<Server> newServers);//向负载均衡器维护的实例清单中增加服务实例
   public Server chooseServer(Object key);//通过某种策略,选择一个具体的服务实例
   public void markServerDown(Server server);//标识出某个服务已经停止服务
   public List<Server> getReachableServers();//获取正常服务的实例清单
   public List<Server> getAllServers();//获取所有服务的实例清单

负载均衡

负载均衡器

我们先看下ILoadBalancer接口的类图:

AbstractLoadBalancer:是接口的抽象实现,主要功能是把实例进行分组并提供获取实例方法。

public enum ServerGroup{
    ALL,//所有实例
    STATUS_UP,//正常服务实例
    STATUS_NOT_UP //停止服务实例       
}

BaseLoadBalancer:是Ribbon负载均衡器的基础实现类,包含的主要是一些基础内容,比如:存储服务实例的列表,检查服务实例是否正常服务的IPing对象,定义负载均衡的处理规则IRule(这也是所有负载均衡策略的接口)对象,选择一个具体服务实例(默认使用线性轮询的方式)等等。

NoOpLoadBalancer:一个什么也不做的负载均衡器。

DynamicServerListLoadBalancer:对BaseLoadBalancer的扩展,在基础功能上增加了动态更新服务实例清单和过滤的功能,仍然使用线性轮询方式进行具体服务实例的选择。

ZoneAwareLoadBalancer:对DynamicServerListLoadBalancer的扩展,增加了按区域的概念。它父类的负载均衡器都是把所有实例视为一个Zone下的节点进行轮询的,所以当我们有多个区域的情况下,势必会造成周期性的跨区域访问问题。使用了该负载均衡器,就可以避免这种问题。我们可以在RibbonClientConfiguration类中查看:

@Bean
@ConditionalOnMissingBean
public ILoadBalancer ribbonLoadBalancer(IClientConfig config,
      ServerList<Server> serverList, ServerListFilter<Server> serverListFilter,
      IRule rule, IPing ping, ServerListUpdater serverListUpdater) {
   if (this.propertiesFactory.isSet(ILoadBalancer.class, name)) {
      return this.propertiesFactory.get(ILoadBalancer.class, config, name);
   }
   return new ZoneAwareLoadBalancer<>(config, rule, ping, serverList,
         serverListFilter, serverListUpdater);
}

ZoneAwareLoadBalancer是默认整合时采用的负载均衡器,它内部使用的是ZoneAvoidanceRule策略来实现的,各种负载均衡策略的具体介绍,参考下节内容。

负载均衡策略

整个负载均衡策略相关类的关系图如下:

IRule:所有负载均衡器的统一接口,提供选择负载均衡器功能

AbstractLoadBalancerRule: 定义了负载均衡器ILoadBalancer

RandomRule:随机选择策略,从服务实例清单中随机取一个。

RoundRobinRule:按照线性轮询的方式依次选择每个服务实例的策略。

RetryRule:内部使用了RoundRobinRule负载策略,增加了重试机制,通过时间来控制。

WeightedResponseTimeRule:增加了权重的线性轮询机制,主要由定时任务(默认30秒计算一次)、权重计算(根据实例的响应时间按规则计算)、实例选择三部分功能组成。

ClientConfigEnabledRoundRobinRule:本身也是通过RoundRobinRule来实现策略的,它的主要作用是做为一个父类,使得它所有子类具备线性轮询的功能同时可以和扩展自己的特性。

BestAvailableRule:最空闲实例负载策略,选择一个目前负载量最小的实例。

PredicateBasedRule:具备过滤机制的线性轮询负载策略。

AvailabilityFilteringRule:可用的最大空闲负载策略,这里的可用指的是:1 断路器处于关闭状态 2 并发数是小于阈值(默认2的32次减1)的

ZoneAvoidanceRule:区域选择负载策略,这个策略的逻辑稍微有点复杂,大致规则如下:

  • 为所有Zone区域创建快照
  • 计算出可用区域:首先剔除符合下面这些规则的Zone区域:实例数为0的区域,平均负载小于0的区域,故障了大于阈值(默认0.99999)的区域 ;然后根据平均负载计算出最差的Zone区域;如果上面过程没有符合剔除条件的区域并且平均负载小于阈值(20%),就直接返回所有可用的区域;否则从最坏的区域集合中随机选择一个剔除
  • 当区域数集合不为空,随机选择一个
  • 确定某个区域后,获取区域的服务实例清单轮询选择

在上面的类图中,我把所有策略中最为重要的两个策略用绿色标记了下,为什么我认为他们最重要呢?RoundRobinRule是其他所有负责策略的基础,也是我们通常情况下使用最多的策略;ZoneAvoidanceRule是我们需要对所有微服务实例进行有效管理和最优化实施的关键策略,特别是有跨区域的实例时。

配置详解

自动化配置

由于Ribbon中定义的每一个接口都有多种不同的策略实现,同时这些接口之间又有一定的依赖关系,这使得第一次使用Ribbon的开发者很难上手,不知道如何选择具体的实现策略以及如何组织它们的关系。SpringCloudRibbon中的自动化配置恰恰能够解决这样的痛点,在引入Spring Cloud Ribbon的依赖之后,就能够自动化构建下面这些接口的实现。

  • IClientConfig:Ribbon 的客户端配置, 默认采用com.netflix.client.config.DefaultClientConfigimpl实现
  • IRule:Ribbon 的负载均衡策略, 默认采用com.netflix.loadbalancer.ZoneAvoidanceRule实现,该策略能够在多区域环境下选出最佳区域的实例进行访问
  • IPing:Ribbon的实例检查策略,默认采用com.netflix.loadbalancer.NoOping实现, 该检查策略是一个特殊的实现,实际上它并不会检查实例是否可用, 而是始终返回true, 默认认为所有服务实例都是可用的。
  • ServerList:服务实例清单的维护机制, 默认采用com.netflix.loadbalancer.ConfigurationBasedServerList实现
  • ServerListFilter:服务实例清单过滤机制, 默认采用org.springframework.cloud.netflix.ribbon.ZonePreferenceServerListFilter实现, 该策略能够优先过滤出与请求调用方处于同区域的服务实例。
  • ILoadBalancer:负载均衡器, 默认采用com.netflix.loadbalancer.ZoneAwareLoadBalancer实现, 它具备了区域感知的能力。

上面这些自动化配置内容仅在没有引入Spring Cloud Eureka等服务治理框架时如此,在同时引入Eureka和Ribbon依赖时,自动化配置会有一些不同。如果要看这些类具体的配置了些什么,只要查看对应的类具体代码就可以详细了解。

我们也可以使用配置类方便地替换上面的这些默认实现,比如:

@Configuration
public class MyRibbonConfiguration {

@Bean //默认的NoOping 就不会被创建
public IPing ribbonPing(IClientConfig config) {
return new PingUrl();
    }


@RibbonClient(name = "hello-service", configura七ion = HelloServiceConfiguration.
class) //为服务指定配置
public class RibbonConfiguration {
    }
}

参数配置

支持二种方式的配置:

  • 全局配置
    ribbon.< key>=< value>格式进行配置即可。代表了Ribbon 客户端配置的参数名, < value>则代表了对应参数的
    值。例如:ribbon.ConnectTimeout=250,配置了全局的Ribbon创建连接时间。
    一般建议全局配置可以作为系统的默认配置,客户端配置可以覆盖全局配置,从而实现自定义的一些配置

  • 指定客户端配置
    配置方式采用< client> .ribbon.< key>=< value>的格式进行配置。其中, < key>和< value>的含义同全局配置相同, 而< client>代表了客户端的名称, 比如使用@RibbonClient指定的名称, 也可以将它理解为是一个服务名。

与Eureka结合

当在Spring Cloud的应用中同时引入Spring Cloudribbon和Spring Cloud Eureka依赖时, 会触发Eureka中实现的对ribbon的自动化配置。ServerList的维护机制,IPing的实现都将被覆盖。

在与Spring Cloud Eureka结合使用的时候, 我们的配置将会变得更加简单。不再需要通过类似hello-service.ribbon.listOfServers的参数来指定具体的服务实例清单, 因为Eureka将会为我们维护所有服务的实例清单。而对于Ribbon 的参数配置, 我们依然可以采用之前的两种配置方式来实现, 而指定客户端的配置方式可以直接使用Eureka中的服务名作为来完成针对各个微服务的个性化配置。

SpringCloudRibbon默认实现了区域亲和策略,所以, 我们可以通过Eureka实例的元数据配置来实现区域化的实例配置方案。
将处于不同机房的实例配置成不同的区域值, 以作为跨区域的容错机制实现。例如:

eureka.instance.metadataMap.zone=hangzhou

最后,如果你喜欢或有需要使用Ribbon来维护服务实例,也可以通过参数配置的方式来禁用Eureka对Ribbon服务实例的维护实现。例如:

ribbon.eureka.enabled=false

重试机制

Spring Cloud Eureka实现的服务智力机制强调了CAP原理中的AP(可用性和分区容错性),不同于类似ZK这类强调CP(一致性和分区容错性) 的服务治理框架。Eureka为了可用性,牺牲了一定的一致性。

在极端情况下,它宁愿接收故障的服务实例,也不要丢掉健康实例。比如在注册中心的网络发生故障时,CP优先的服务治理将会把所有的服务实例全部剔除,而Eureka则会因为超过85%的实例丢掉心跳触发保护机制,注册中心会保留此时的所有节点。

所以当服务调用到故障实例的时候,我们希望能够增强对这类问题的容错,这时可以使用Spring Retry(SpringCloud已经做了整合)来增强RestTemplate的重试能力。

要实现上面所说的重试机制我们只要增加相关配置就行:

spring.cloud.loadbalancer.retry.enabled = true //开启重试机制

然后是对具体服务进行一些超时时间的配置:

hystrix.command.default.execution.isolation.thread.timeoutinMilliseconds=l0000 //断路器超时时间
xxxservice.ribbon.ConnectTimeout=250 //请求连接超时时间
xxxservice.ribbon.ReadTimeout= l000 //请求处理的超时时间
xxxservice.ribbon.OkToRetryOnAllOperations=true //对所有操作请求都进行重试
xxxservice.ribbon.MaxAutoReriesNextServer=2 //切换实例的重试次数
xxxservice.ribbon.MaxAutoRetries=1   //对当前实例的重试次数

注意断路器的超时时间需要大于Ribbon的超时时间,不然不会触发重试,原理很简单,因为先触发断路器的逻辑了。

SpringCloud微服务系列(2)

hzqiuxm阅读(218)评论(0)

注册中心Eureka

起源

Spring Cloud Eureka是Spring Cloud Netflix微服务套件中的一部分,它基于Netflix Eureka做了二次封装,主要负责完成微服务架构中的服务治理功能。

基本介绍

为什么需要注册中心

微服务的早期,我们可以通过一些静态配置或者软件负载来完成服务之间的负载均衡调用,但随着微服务的不断增加,静态配置就会暴露出一些问题:

  • 静态配置维护越来越复杂
  • 集群规模越来越大,人工维护成本高
  • 服务的位置可能会发生变化,灵活性差,调整成本高
  • 服务的名称都有可能发生变化,难以维护

所以为了有效解决以上的问题,我们需要引入服务治理。服务治理一般包含三个角色:

  • 服务注册中心:每个服务都要在注册中心进行注册登记自己的服务(主机,端口,版本,协议等),服务中心会提供心跳维护。
  • 服务提供者:提供服务的一方,就是服务的被调用方
  • 服务消费者:服务的调用方,当然本身也可以是其他服务的提供者

也就是说,在服务治理的框架下,服务间的调用不再通过指定具体的实例地址来实现,而是通过服务名来实现。
服务的调用方在注册中心查询到可用的服务清单后,可以采用不同的负载均衡方式进行调用。

Spring Cloud Eureka能做什么

  • 既包含了服务端组件(作为注册中心),又包含了客户端组件(作为服务提供者,处理服务的注册与发现)
  • 服务端和客户端均以java实现,非常适合通过java实现的分布式系统或与JVM兼容的其他语言构成的系统
  • 提供了完备RESTful API,支持非java语言构建的微服务纳入进来,不过其他语言要实现自己的客户端程序(很多语言都已经有实现)

核心知识

搭建服务中心Eureka Server

依赖于:spring-cloud-starter-eureka-server (默认是服务端和客户端为一体的)

关键注解
  • 在启动类上加上@EnableEurekaServer注解,使其成为注册中心
关键配置
  • 注册中心服务端口
  • 注册中心服务实例名称
  • 默认客户端配置是打开的,注册中心也会将自己作为客户端来尝试注册自己,所以我们需要禁用它的客户端注册行为
  • 如果不是高可用的多中心配置,也不需要和其他注册中心进行同步,检索其他注册中心服务也要禁用

一个单中心注册服务配置示例:

server:
  port: 8761

eureka:
  instance:
    hostname: localhost
  # 每个Eureka server 也是一个client 所以把client相关配置关闭掉,只作为服务使用
  client:
    # 只作为服务端
    registerWithEureka: false
    # 不需要同其他的注册中心同步信息
    fetchRegistry: false
    serviceUrl:
      defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
  # 关闭注册中心的保护模式,如果90秒收不到心跳信息,将销毁某个注册者信息,
  # 开启保护模式时,即使服务提供者宕机或无法提供服务,注册中心仍然会保留注册信息
  server:
    enable-self-preservation: false

客户端注册到注册中心

  • 客户端的依赖和服务端一样
  • 客户端可以使用@EnableEurekaClient注解,也可以使用@EnableDiscoveryClient注解注册为服务的客户端
  • 二者区别:当Eureka在classpath下的话,二者没有区别。@EnableDiscoveryClient可以支持其他的服务发现组件,比如zk
  • 最后客户端只要添加以下配置就可以注册到服务端注册中心了
eureka:
  client:
    service-url:
      #注册中心地址
      defaultZone: http://localhost:8761/eureka/

  # 以IP地址方式注册,默认是hostname
  instance:
    ip-address: true
server:
  port: 8762
# service name
spring:
  application:
    name: service-hi

我们给客户端添加一个简单的hello world服务

@RestController
@EnableEurekaClient
@SpringBootApplication
public class EurekaclientApplication {

   public static void main(String[] args) {
      SpringApplication.run(EurekaclientApplication.class, args);
   }

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

   @RequestMapping("/hi")
   public String home(@RequestParam String name){

      return "hi " + name +", i am from port" + port;
   }
}

#### 简单测试

分别启动服务端和客户端

服务端,端口:8761
java -jar eureka-server-1.0.0.jar 

客户端,端口:8762
java -jar eureka-client-1.0.0.jar 

访问:localhost:8761可以查看服务端注册中心的监控页面,看到我们的客户端服务已经注册到注册中心了

尝试通过url,http://localhost:8762?name=hziquxm 来访问下服务,客户端的服务则会返回

hi hzqiuxm, i am from port8762

Eureka Server 的高可用

在分布式服务应用中,高可用是必须考虑的事情,我们注册中心也需要具备高可用才行,所以下面简单介绍下注册中心的高可用

基本思想
  • 实现思想:所有节点是服务提供方,也是服务消费方,服务中心也一样,多个Eureka之间相互注册实现高可用

  • 建议按照优先级命名方式比如:profile:primary ,secondary, tertiary,当然也可以采用自己喜欢的简单命名方式,比如:ha1,ha2......

高可用注册中心架构图:

注意:Eureka微服务客户端只要注册到其中一个服务端即可

主要配置
  • 配置关键点:每个Eureka名字相同, 实例名称不同,端口号只要不在一台主机上建议都设置相同,便于统一管理
  • 不同中心启动的时候指定对应的配置文件中不同的段,例如:java -jar eureka-server-1.0.0.jar -- spring.profiles.active=ha1

一个高可用注册中心服务配置示例:

# ha service name
spring:
  application:
    name: service-hi-ha
---
spring:
  profiles: ha1
server:
  port: 8761
eureka:
  instance:
    # profiles = ha1
    hostname: ha1
  client:
    serviceUrl:
      # 将自己注册到ha2
      defaultZone: http://ha2:8771/eureka/

---
spring:
  profiles: ha2
server:
  port: 8771
eureka:
  instance:
    # profiles = ha2
    hostname: ha2
  client:
    serviceUrl:
      # 将自己注册到ha1
      defaultZone: http://ha1:8761/eureka/

简单示例

我们分别启动二个注册中心(其实从数学理论角度看,3个是最佳的,3个的配置也很简单,就是在其中一个中心注册地址defaultZone后面加上另外2个中心地址即可,用逗号分开):
为了演示方便,我这里就举2个中心的例子

注册中心1,端口:8761
java -jar eureka-server-1.0.0.jar -- spring.profiles.active=ha1

注册中心2,端口:8771
java -jar eureka-server-1.0.0.jar -- spring.profiles.active=ha2

客户端服务,端口:8762,只注册到8761
java -jar eureka-client-1.0.0.jar 

我们访问8761和8771的注册中心监视界面:
下面是8761

数字1:此列出了注册到8761上的实例,发现除了客户端服务外,注册中心本身
数字2:此处指出了8761注册中心注册到了8771端口的注册中心
数字3:此处指出了8761的备份节点是8771

下面是8771

数字1:可以发现我们之前客户端只是注册到了8761端口的注册中心,但在此处,也可以发现此实例
数字2:此处指出了8771注册中心注册到了8761端口的注册中心
数字3:此处指出了8771的备份节点是8761

组件详解

基础架构与通信行为

Eureka作为服务治理框架,其基础架构主要包含了三个核心要素

  • 服务注册中心:就是本节中的Eureka服务端,又称之为注册中心
  • 服务生产者:就是本节中的Eureka客户端,扮演作用是服务的提供者
  • 服务消费者:本节上面没有演示,其实也是Eureka客户端,扮演的作用是服务的消费者

注意:上面的服务提供者和服务消费者在实际应用中并不是单一职责的,服务B可能是服务A的提供者,同时也可能是服务C的消费者,是一个相对的概念。

下图是三者关系调用图:

服务生产者:主要有三种操作,服务注册、服务续约、服务下线

服务注册,服务的提供者在启动的时候通过发送REST请求将自己注册到Eureka Server上。Eureka Server接收到这个信息后,
会把发送请求中关于服务提供者的元数据信息存放在一个双层的Map中。

类似下面的结构:第一层key是服务名,value是这个服务下的所有实例;第二层key是实例名,value是具体实例元数据信息

因为上图中的架构是高可用的架构,所以注册中心之间还会有个服务同步的操作,在一方注册的服务提供者信息会被同步到另一方的注册中心。

通过服务同步,服务提供者的服务就可以从这两台注册中心中的任意一台上获得,从而实现了高可用。

服务续约,在注册完服务后,服务提供者会维护一个心跳来持续告诉注册中心它还活着,以防止注册中心从服务列表中剔除没有心跳的服务实例。

服务续约的两个重要属性是

EurekaInstanceConfigBean类
private int leaseRenewalIntervalInSeconds = 30; //续约服务调用间隔时间
private int leaseExpirationDurationInSeconds = 90;//定义服务失效时间

服务下线,在服务关闭时候,会触发一个服务下线的REST请求给注册中心,注册中心收到请求后,将该服务状态置为下线,
并把该下线事件传播出去(同步给其他注册中心或以及通知服务消费者)。

服务的消费者:主要操作二个,获取服务、服务调用。

获取服务,服务消费者在启动的时候会发送一个REST请求给服务注册中心,获取在上面注册的服务清单。
出于性能考虑,注册中心Eureka Server只会维护一份只读的清单缓存用来返回给客户端使用,清单默认每隔30秒刷新一次

EurekaClientConfigBean类
private int registryFetchIntervalSeconds = 30; //刷新时间配置的属性与默认值

服务调用,服务消费者在获取了服务清单后,通过服务名可以获得具体提供服务的实例名和元数据信息(参考上面的双层Map图)。
客户端可以根据自己的需要来决定具体调用哪个,所以一般我们会在服务消费者端集成类似Ribbon,Feign这样的负载工具。

这里需要补充的是对于访问实例的选择,Eureka中有Region和Zone概念,它们的关系如下图所示:

一个Region中可以包含多个Zone,Zone中包含了服务的实例。细心的读者会发现我们之前的配置文件中有这么一段

  client:
    serviceUrl:
      # 将自己注册到ha1
      defaultZone: http://ha1:8761/eureka/

这里的defaultZone就是服务默认注册的Zone,我们也可以自己设置Region和Zone

EurekaClientConfigBean类
private String region = "us-east-1";//默认的region名字
private Map<String, String> availabilityZones = new HashMap<>();//多个Zone用逗号分开

消费者在进行服务调用时,优先访问同一个Zone的服务,若访问不到就会访问其他Zone。

最佳实践提醒:利用上面这个特点我们可以用一个Zone代表一个物理区域(物理主机或集群),设计出具备区域故障容错的微服务集群。

关键源码分析

我们把一个普通的SpringBoot应用注册到Eureka Server注册中心时,主要做了两件事:

  • 在应用主类中配置了@EnableDiscoveryClient或者@EnableEurekaClient注解
  • 在配置文件中用eureka.client.serviceUrl.defaultZone参数指定了服务中心的位置

那这一切是如何发生的呢?我们顺着这两个线索,一起去看看它们背后的实现原理。

首先我们看下@EnableDiscoveryClient注解的源码:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Import(EnableDiscoveryClientImportSelector.class)
public @interface EnableDiscoveryClient {

   /**
    * If true, the ServiceRegistry will automatically register the local server.
    */
   boolean autoRegister() default true;
}

从上面的代码中我们可以看出来,它主要用来开启DiscoveryClient实例。由这个类我们梳理下和它相关的类,以及他们之间的关系如下:

为了让大家看清楚它们之间的关系我用两种不同的颜色区分了Netflix包下的类和SpringCloud包下面的类。真正实现发现服务的
则是Netflix 包中的com.netflix.discovery.DiscoveryClient 类,我们就来详细看看DiscoveryClient 类功能吧,它的主要作用就是与注册中心Eureka Server进行交互,上一节中我们说了它的功能主要有:向注册中心注册服务实例、向注册中心服务租约、服务关闭时取消租约、查询注册中心服务实例列表。

DiscoveryClient 中提供下非常多的方法,在这里就不一一说明,举上面的注册来说说吧,希望大家可以举一反三。

通过查看它的构造类, 可以找到它调用了下面这个函数:

private void initScheduledTasks() {
    if (clientConfig.shouldFetchRegistry()) {
        // registry cache refresh timer
        ...

    if (clientConfig.shouldRegisterWithEureka()) { //注释一
        ...

        // InstanceInfo replicator
        instanceInfoReplicator = new InstanceInfoReplicator(
                this,
                instanceInfo,
                clientConfig.getInstanceInfoReplicationIntervalSeconds(),
                2); // burstSize
    ...
        instanceInfoReplicator.start(clientConfig.getInitialInstanceInfoReplicationIntervalSeconds());
    } else {
        logger.info("Not registering with Eureka server per configuration");
    }
}

大家可以看到在注释一处判断了是否要注册到注册中心,条件为真后创建了一个InstanceInfoReplicator实例,它实现了Runnable接口,所以会启动一个线程来处理。

果然在后面的代码中InstanceInfoReplicator实例启动了start方法,我们赶紧去它的Run方法里看下,它启动后干了点什么:

public void run() {
    try {
        discoveryClient.refreshInstanceInfo();

        Long dirtyTimestamp = instanceInfo.isDirtyWithTime();
        if (dirtyTimestamp != null) {
            discoveryClient.register();//注释二
            instanceInfo.unsetIsDirty(dirtyTimestamp);
        }
    } catch (Throwable t) {
        logger.warn("There was a problem with the instance info replicator", t);
    } finally {
        Future next = scheduler.schedule(this, replicationIntervalSeconds, TimeUnit.SECONDS);
        scheduledPeriodicRef.set(next);
    }
}

不负所望,在它的run方法里注释二处,我们看到了discoveryClient.register(),这一行真正触发了注册的动作。我们再进入注册方法中看看:

/**
 * Register with the eureka service by making the appropriate REST call.
 */
boolean register() throws Throwable {
    logger.info(PREFIX + appPathIdentifier + ": registering service...");
    EurekaHttpResponse<Void> httpResponse;
    try {
        httpResponse = eurekaTransport.registrationClient.register(instanceInfo);
    } catch (Exception e) {
        logger.warn("{} - registration failed {}", PREFIX + appPathIdentifier, e.getMessage(), e);
        throw e;
    }
    if (logger.isInfoEnabled()) {
        logger.info("{} - registration status: {}", PREFIX + appPathIdentifier, httpResponse.getStatusCode());
    }
    return httpResponse.getStatusCode() == 204;
}

相信一切都真相大白了,注册操作通过REST请求方式进行,注册时传入的instanceInfo就是客户端给服务端的元数据,如果你对元数据看兴趣的话,就进入InstanceInfo中去看看吧。

配置详解

服务注册类

这部分主要负责配置注册中心的地址、服务获取的间隔时间、可用区域等。
服务注册类的配置,我们可以查看源码中的org.springframemwork.cloud.netflix.eureka.EurekaClientConfigBean类,该类中的属性基本都是可以进行配置的,比看官方的文档还要全。
比如上面我们提到的属性:registryFetchIntervalSeconds = 30,用来设置缓存中服务清单刷新时间,30表示默认值。我们如果配置成100秒的话可以这么配置:

eureka.client.registry-fetch-interval-seconds=100
服务实例类

这部分主要负责配置服务实例的名称、IP地址、端口号、健康检查路径等。
服务实例类配置我们可以查看源码中的org.springframemwork.cloud.netflix.eureka.EurekaInstanceConfigBean类。
比如我们上面提到的属性:leaseRenewalIntervalInSeconds = 30,用来设置续约时间,30表示默认值。我们如果要配置成90秒的话,可以:

eureka.client.lease-renewal-interval-in-seconds=90

跨平台支持

其他语言客户端

因为采用了HTTP的REST接口方式,使得Eureka Server下注册的微服务不限于使用Java开发。
除了Java实现了Eureka的客户端外,有JS的实现eureka-js-client,Python的实现python-eureka,即使是你自己来为某门语言来开一个客户端,
也并不是十分复杂,只需要根据上面提到的那些用户服务协调的通信请求实现就能实现服务的注册与发现,有兴趣的同学可以参考官方的API。

通信协议

默认情况下,Eureka使用Jersey和XStream配合JSON作为Server和Clinet之间的通信协议。
Jersey是JAX-RS规范的参考实现,主要包含:核心服务器,核心客户端,集成三个部分。
XStream是用来将对象序列化或反序列化操作一个Java类库。

欢迎加入紫牛小筑

进入小筑关于作者