Fork me on GitHub

单体应用改造为微服务架构后,服务调用由本地调用变成远程调用,服务消费者 A 需要通过注册中心去查询服务提供者 B 的地址,然后发起调用,这个看似简单的过程就可能会遇到下面几种情况,比如:

  • 注册中心宕机;
  • 服务提供者 B 有节点宕机;
  • 服务消费者 A 和注册中心之间的网络不通;
  • 服务提供者 B 和注册中心之间的网络不通;
  • 服务消费者 A 和服务提供者 B 之间的网络不通;
  • 服务提供者 B 有些节点性能变慢;
  • 服务提供者 B 短时间内出现问题。

可见,一次服务调用,服务提供者、注册中心、网络这三者都可能会有问题,此时服务消费者应该如何处理才能确保调用成功呢?这就是服务治理要解决的问题。
接下来我们一起来看看常用的服务治理手段。

节点管理

服务调用失败一般是由两类原因引起的,一类是服务提供者自身出现问题,如服务器宕机、进程意外退出等;一类是网络问题,如服务提供者、注册中心、服务消费者这三者任意两者之间的网络出现问题。

无论是服务提供者自身出现问题还是网络发生问题,都有两种节点管理手段。

    1. 注册中心主动摘除机制
      这种机制要求服务提供者定时的主动向注册中心汇报心跳,注册中心根据服务提供者节点最近一次汇报心跳的时间与上一次汇报心跳时间做比较,如果超出一定时间,就认为服务提供者出现问题,继而把节点从服务列表中摘除,并把最近的可用服务节点列表推送给服务消费者。
    1. 服务消费者摘除机制
      虽然注册中心主动摘除机制可以解决服务提供者节点异常的问题,但如果是因为注册中心与服务提供者之间的网络出现异常,最坏的情况是注册中心会把服务节点全部摘除,导致服务消费者没有可用的服务节点调用,但其实这时候服务提供者本身是正常的。所以,将存活探测机制用在服务消费者这一端更合理,如果服务消费者调用服务提供者节点失败,就将这个节点从内存中保存的可用服务提供者节点列表中移除。

负载均衡

一般情况下,服务提供者节点不是唯一的,多是以集群的方式存在,尤其是对于大规模的服务调用来说,服务提供者节点数目可能有上百上千个。由于机器采购批次的不同,不同服务节点本身的配置也可能存在很大差异,新采购的机器 CPU 和内存配置可能要高一些,同等请求量情况下,性能要好于旧的机器。对于服务消费者而言,在从服务列表中选取可用节点时,如果能让配置较高的新机器多承担一些流量的话,就能充分利用新机器的性能。这就需要对负载均衡算法做一些调整。

常用的负载均衡算法主要包括以下几种。

    1. 随机算法
      顾名思义就是从可用的服务节点中随机选取一个节点。一般情况下,随机算法是均匀的,也就是说后端服务节点无论配置好坏,最终得到的调用量都差不多。
    1. 轮询算法
      就是按照固定的权重,对可用服务节点进行轮询。如果所有服务节点的权重都是相同的,则每个节点的调用量也是差不多的。但可以给某些硬件配置较好的节点的权重调大些,这样的话就会得到更大的调用量,从而充分发挥其性能优势,提高整体调用的平均性能。
    1. 最少活跃调用算法
      这种算法是在服务消费者这一端的内存里动态维护着同每一个服务节点之间的连接数,当调用某个服务节点时,就给与这个服务节点之间的连接数加 1,调用返回后,就给连接数减 1。然后每次在选择服务节点时,根据内存里维护的连接数倒序排列,选择连接数最小的节点发起调用,也就是选择了调用量最小的服务节点,性能理论上也是最优的。
    1. 一致性 Hash 算法
      指相同参数的请求总是发到同一服务节点。当某一个服务节点出现故障时,原本发往该节点的请求,基于虚拟节点机制,平摊到其他节点上,不会引起剧烈变动。

这几种算法的实现难度也是逐步提升的,所以选择哪种节点选取的负载均衡算法要根据实际场景而定。如果后端服务节点的配置没有差异,同等调用量下性能也没有差异的话,选择随机或者轮询算法比较合适;如果后端服务节点存在比较明显的配置和性能差异,选择最少活跃调用算法比较合适。

服务路由

对于服务消费者而言,在内存中的可用服务节点列表中选择哪个节点不仅由负载均衡算法决定,还由路由规则确定。

所谓的路由规则,就是通过一定的规则如条件表达式或者正则表达式来限定服务节点的选择范围。

为什么要制定路由规则呢?主要有两个原因。

1. 业务存在灰度发布的需求

比如,服务提供者做了功能变更,但希望先只让部分人群使用,然后根据这部分人群的使用反馈,再来决定是否做全量发布。这个时候,就可以通过类似按尾号进行灰度的规则限定只有一定比例的人群才会访问新发布的服务节点。

2. 多机房就近访问的需求

据我所知,大部分业务规模中等及以上的互联网公司,为了业务的高可用性,都会将自己的业务部署在不止一个 IDC 中。这个时候就存在一个问题,不同 IDC 之间的访问由于要跨 IDC,通过专线访问,尤其是 IDC 相距比较远时延迟就会比较大,比如北京和广州的专线延迟一般在 30ms 左右,这对于某些延时敏感性的业务是不可接受的,所以就要一次服务调用尽量选择同一个 IDC 内部的节点,从而减少网络耗时开销,提高性能。这时一般可以通过 IP 段规则来控制访问,在选择服务节点时,优先选择同一 IP 段的节点。

那么路由规则该如何配置呢?一般有两种配置方式。

    1. 静态配置
      就是在服务消费者本地存放服务调用的路由规则,在服务调用期间,路由规则不会发生改变,要想改变就需要修改服务消费者本地配置,上线后才能生效。
    1. 动态配置
      这种方式下,路由规则是存在注册中心的,服务消费者定期去请求注册中心来保持同步,要想改变服务消费者的路由配置,可以通过修改注册中心的配置,服务消费者在下一个同步周期之后,就会请求注册中心来更新配置,从而实现动态更新。

服务容错

服务调用并不总是一定成功的,可能因为服务提供者节点自身宕机、进程异常退出或者服务消费者与提供者之间的网络出现故障等原因。对于服务调用失败的情况,需要有手段自动恢复,来保证调用成功。

常用的手段主要有以下几种。

  • FailOver:失败自动切换。就是服务消费者发现调用失败或者超时后,自动从可用的服务节点列表总选择下一个节点重新发起调用,也可以设置重试的次数。这种策略要求服务调用的操作必须是幂等的,也就是说无论调用多少次,只要是同一个调用,返回的结果都是相同的,一般适合服务调用是读请求的场景。
  • FailBack:失败通知。就是服务消费者调用失败或者超时后,不再重试,而是根据失败的详细信息,来决定后续的执行策略。比如对于非幂等的调用场景,如果调用失败后,不能简单地重试,而是应该查询服务端的状态,看调用到底是否实际生效,如果已经生效了就不能再重试了;如果没有生效可以再发起一次调用。
  • FailCache:失败缓存。就是服务消费者调用失败或者超时后,不立即发起重试,而是隔一段时间后再次尝试发起调用。比如后端服务可能一段时间内都有问题,如果立即发起重试,可能会加剧问题,反而不利于后端服务的恢复。如果隔一段时间待后端节点恢复后,再次发起调用效果会更好。
  • FailFast:快速失败。就是服务消费者调用一次失败后,不再重试。实际在业务执行时,一般非核心业务的调用,会采用快速失败策略,调用失败后一般就记录下失败日志就返回了。

它们的使用场景是不同的,一般情况下对于幂等的调用,可以选择 FailOver 或者 FailCache,非幂等的调用可以选择 FailBack 或者 FailFast。

总结

上面这些服务治理的手段是最常用的手段,它们从不同角度来确保服务调用的成功率。节点管理是从服务节点健康状态角度来考虑,负载均衡和服务路由是从服务节点访问优先级角度来考虑,而服务容错是从调用的健康状态角度来考虑,可谓是殊途同归。

在实际的微服务架构实践中,上面这些服务治理手段一般都会在服务框架中默认集成了,比如阿里开源的服务框架 Dubbo、微博开源的服务框架 Motan 等,不需要业务代码去实现。如果想自己实现服务治理的手段,可以参考这些开源服务框架的实现。

XML 配置方式的服务发布和引用流程

1. 服务提供者定义接口

服务提供者发布服务之前首先要定义接口,声明接口名、传递参数以及返回值类型,然后把接口打包成 JAR 包发布出去。

比如下面这段代码,声明了接口UserLastStatusService,包含两个方法getLastStatusIdgetLastStatusIds,传递参数一个是long值、一个是long数组,返回值一个是long值、一个是map

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.weibo.api.common.status.service;

public interface UserLastStatusService {
* @param uids
* @return
*/
public long getLastStatusId(long uid);

/**
*
* @param uids
* @return
*/
public Map<Long, Long> getLastStatusIds(long[] uids);
}

2. 服务提供者发布接口

服务提供者发布的接口是通过在服务发布配置文件中定义接口来实现的。

下面是一个具体的服务发布配置文件user-last-status.xml,它定义了要发布的接口userLastStatusLocalService,对外暴露的协议是 Motan 协议,端口是 8882。并且针对两个方法getLastStatusIdgetLastStatusIds,通过requestTimeout="300"单独定义了超时时间是 300ms,通过retries="0"单独定义了调用失败后重试次数为 0,也就是不重试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:aop="http://www.springframework.org/schema/aop"
xsi:schemaLocation="http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context-2.5.xsd
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-2.5.xsd
http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-2.5.xsd
">

<motan:service ref="userLastStatusLocalService"
requestTimeout="50" retries="2" interface="com.weibo.api.common.status.service.UserLastStatusService"
basicService="serviceBasicConfig" export="motan:8882">
<motan:method name="getLastStatusId" requestTimeout="300"
retries="0" />
<motan:method name="getLastStatusIds" requestTimeout="300"
retries="0" />
</motan:service>

</beans>

然后服务发布者在进程启动的时候,会加载配置文件user-last-status.xml,把接口对外暴露出去。

3. 服务消费者引用接口

服务消费者引用接口是通过在服务引用配置文件中定义要引用的接口,并把包含接口定义的 JAR 包引入到代码依赖中。

下面我再以一个具体的服务引用配置文件user-last-status-client.xml来给你讲解,它定义服务消费者引用了接口commonUserLastStatusService,接口通信协议是 Motan。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:aop="http://www.springframework.org/schema/aop"
xsi:schemaLocation="http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context-2.5.xsd
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-2.5.xsd
http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-2.5.xsd
">
<motan:protocol name="motan" default="true" loadbalance="${service.loadbalance.name}" />
<motan:basicReferer id="userLastStatusServiceClientBasicConfig"
protocol="motan" />

<!-- 导出接口 -->
<motan:referer id="commonUserLastStatusService" interface="com.weibo.api.common.status.service.UserLastStatusService"
basicReferer="userLastStatusServiceClientBasicConfig" />

</beans>

然后服务消费者在进程启动时,会加载配置文件user-last-status-client.xml来完成服务引用。

服务发布和引用流程看似比较简单,但在实际使用过程中,还是有很多坑的,比如在实际项目中经常会遇到这个问题:一个服务包含了多个接口,可能有上行接口也可能有下行接口,每个接口都有超时控制以及是否重试等配置,如果有多个服务消费者引用这个服务,是不是每个服务消费者都必须在服务引用配置文件中定义?

服务发布和引用的那些坑

在一个服务被多个服务消费者引用的情况下,由于业务经验的参差不齐,可能不同的服务消费者对服务的认知水平不一,比如某个服务可能调用超时了,最好可以重试来提供调用成功率。但可能有的服务消费者会忽视这一点,并没有在服务引用配置文件中配置接口调用超时重试的次数,因此最好是可以在服务发布的配置文件中预定义好类似超时重试次数,即使服务消费者没有在服务引用配置文件中定义,也能继承服务提供者的定义。这就是下面要讲的服务发布预定义配置。

1. 服务发布预定义配置

以下面的服务发布配置文件server.xml为例,它提供了一个服务contentSliceRPCService,并且明确了其中三个方法的调用超时时间为 500ms 以及超时重试次数为 3。

1
2
3
4
5
6
7
8
9
10
11
12
13
<motan:service ref="contentSliceRPCService"       interface="cn.sina.api.data.service.ContentSliceRPCService"
basicService="serviceBasicConfig" export="motan:8882" >
<motan:method name="saveContent" requestTimeout="500"
retries="3" />
<motan:method name="deleteContent" requestTimeout="500"
retries="3" />
<motan:method name="updateContent" requestTimeout="500"
retries="3" />
</motan:service>
假设服务引用的配置文件 client.xml 的内容如下,那么服务消费者就会默认继承服务发布配置文件中设置的方法调用的超时时间以及超时重试次数。

<motan:referer id="contentSliceRPCService" interface="cn.sina.api.data.service.ContentSliceRPCService" basicReferer="contentSliceClientBasicConfig" >
</motan:referer>

通过服务发布预定义配置可以解决多个服务消费者引用服务可能带来的配置复杂的问题,这样是不是最优的解决方案呢?

实际上我还遇到过另外一种极端情况,一个服务提供者发布的服务有上百个方法,并且每个方法都有各自的超时时间、重试次数等信息。服务消费者引用服务时,完全继承了服务发布预定义的各项配置。这种情况下,服务提供者所发布服务的详细配置信息都需要存储在注册中心中,这样服务消费者才能在实际引用时从服务发布预定义配置中继承各种配置。

这里就存在一种风险,当服务提供者发生节点变更,尤其是在网络频繁抖动的情况下,所有的服务消费者都会从注册中心拉取最新的服务节点信息,就包括了服务发布配置中预定的各项接口信息,这个信息不加限制的话可能达到 1M 以上,如果同时有上百个服务消费者从注册中心拉取服务节点信息,在注册中心机器部署为百兆带宽的情况下,很有可能会导致网络带宽打满的情况发生。

面对这种情况,最好的办法是把服务发布端的详细服务配置信息转移到服务引用端,这样的话注册中心中就不需要存储服务提供者发布的详细服务配置信息了。这就是下面要讲的服务引用定义配置。

2. 服务引用定义配置

以下面的服务发布配置文件为例,它详细定义了服务 userInfoService 的各个方法的配置信息,比如超时时间和重试次数等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<motan:service ref="userInfoService" requestTimeout="50" retries="2"                   interface="cn.sina.api.user.service.UserInfoService" basicService="serviceBasicConfig">
<motan:method name="addUserInfo" requestTimeout="300" retries="0"/>
<motan:method name="updateUserPortrait" requestTimeout="300" retries="0"/>
<motan:method name="modifyUserInfo" requestTimeout="300" retries="0"/>
<motan:method name="addUserTags" requestTimeout="300" retries="0"/>
<motan:method name="delUserTags" requestTimeout="300" retries="0"/>
<motan:method name="processUserCacheByNewMyTriggerQ" requestTimeout="300" retries="0"/>
<motan:method name="modifyObjectUserInfo" requestTimeout="300" retries="0"/>
<motan:method name="addObjectUserInfo" requestTimeout="300" retries="0"/>
<motan:method name="updateObjectUserPortrait" requestTimeout="300" retries="0"/>
<motan:method name="updateObjectManager" requestTimeout="300" retries="0"/>
<motan:method name="add" requestTimeout="300" retries="0"/>
<motan:method name="deleteObjectManager" requestTimeout="300" retries="0"/>
<motan:method name="getUserAttr" requestTimeout="300" retries="1" />
<motan:method name="getUserAttrList" requestTimeout="300" retries="1" />
<motan:method name="getAllUserAttr" requestTimeout="300" retries="1" />
<motan:method name="getUserAttr2" requestTimeout="300" retries="1" />

</motan:service>

可以像下面一样,把服务 userInfoService 的详细配置信息转移到服务引用配置文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<motan:referer id="userInfoService" interface="cn.sina.api.user.service.UserInfoService" basicReferer="userClientBasicConfig">
<motan:method name="addUserInfo" requestTimeout="300" retries="0"/>
<motan:method name="updateUserPortrait" requestTimeout="300" retries="0"/>
<motan:method name="modifyUserInfo" requestTimeout="300" retries="0"/>
<motan:method name="addUserTags" requestTimeout="300" retries="0"/>
<motan:method name="delUserTags" requestTimeout="300" retries="0"/>
<motan:method name="processUserCacheByNewMyTriggerQ" requestTimeout="300" retries="0"/>
<motan:method name="modifyObjectUserInfo" requestTimeout="300" retries="0"/>
<motan:method name="addObjectUserInfo" requestTimeout="300" retries="0"/>
<motan:method name="updateObjectUserPortrait" requestTimeout="300" retries="0"/>
<motan:method name="updateObjectManager" requestTimeout="300" retries="0"/>
<motan:method name="add" requestTimeout="300" retries="0"/>
<motan:method name="deleteObjectManager" requestTimeout="300" retries="0"/>
<motan:method name="getUserAttr" requestTimeout="300" retries="1" />
<motan:method name="getUserAttrList" requestTimeout="300" retries="1" />
<motan:method name="getAllUserAttr" requestTimeout="300" retries="1" />
<motan:method name="getUserAttr2" requestTimeout="300" retries="1" />
</motan:referer>

这样的话,服务发布配置文件可以简化为下面这段代码,是不是信息精简了许多。

1
2
<motan:service ref="userInfoService" requestTimeout="50" retries="2"                   interface="cn.sina.api.user.service.UserInfoService" basicService="serviceBasicConfig">
</motan:service>

在进行类似的服务详细信息配置,由服务发布配置文件迁移到服务引用配置文件的过程时,尤其要注意迁移步骤问题,这就是接下来我要给你讲的服务配置升级问题。

3. 服务配置升级

由于引用服务的服务消费者众多,并且涉及多个部门,升级步骤就显得异常重要,通常可以按照下面步骤操作。

各个服务消费者在服务引用配置文件中添加服务详细信息。

服务提供者升级两台服务器,在服务发布配置文件中删除服务详细信息,并观察是否所有的服务消费者引用时都包含服务详细信息。

如果都包含,说明所有服务消费者均完成升级,那么服务提供者就可以删除服务发布配置中的服务详细信息。

如果有不包含服务详细信息的服务消费者,排查出相应的业务方进行升级,直至所有业务方完成升级。

总结

XML 配置方式的服务发布和引用的具体流程,简单来说就是服务提供者定义好接口,并且在服务发布配置文件中配置要发布的接口名,在进程启动时加载服务发布配置文件就可以对外提供服务了。而服务消费者通过在服务引用配置文件中定义相同的接口名,并且在服务引用配置文件中配置要引用的接口名,在进程启动时加载服务引用配置文件就可以引用服务了。

在业务具体实践过程中可能会遇到引用服务的服务消费者众多,对业务的敏感度参差不齐的问题,所以在服务发布的时候,最好预定义好接口的各种配置。在服务规模不大,业务比较简单的时候,这样做比较合适。但是对于复杂业务,虽然服务发布时预定义好接口的各种配置,但在引用的服务消费者众多且同时访问的时候,可能会引起网络风暴。这种情况下,比较保险的方式是,把接口的各种配置放在服务引用配置文件里。

在进行服务配置升级过程时,要考虑好步骤,在所有服务消费者完成升级之前,服务提供者还不能把服务的详细信息去掉,否则可能会导致没有升级的服务消费者引用异常。

单体应用改造为微服务架构后,服务调用从本地调用变成了远程方法调用后,面临的各种不确定因素变多了,一方面你需要能够监控各个服务的实时运行状态、服务调用的链路和拓扑图;另一方面你需要在出现故障时,能够快速定位故障的原因并可以通过诸如降级、限流、切流量、扩容等手段快速干预止损。这个时候就需要微服务治理平台了。

微服务治理平台的基本功能

你可能先会问,到底什么是微服务治理平台?根据我的理解,微服务治理平台就是与服务打交道的统一入口,无论是开发人员还是运维人员,都能通过这个平台对服务进行各种操作,比如开发人员可以通过这个平台对服务进行降级操作,运维人员可以通过这个平台对服务进行上下线操作,而不需要关心这个操作背后的具体实现。

接下来我就结合下面这张图,给你介绍一下一个微服务治理平台应该具备哪些基本功能。

  1. 服务管理

通过微服务治理平台,可以调用注册中心提供的各种管理接口来实现服务的管理。根据我的经验,服务管理一般包括以下几种操作:

服务上下线。当上线一个新服务的时候,可以通过调用注册中心的服务添加接口,新添加一个服务,同样要下线一个已有服务的时候,也可以通过调用注册中心的服务注销接口,删除一个服务。

节点添加 / 删除。当需要给服务新添加节点时候,可以通过调用注册中心的节点注册接口,来给服务新增加一个节点。而当有故障节点出现或者想临时下线一些节点时,可以通过调用注册中心的节点反注册接口,来删除节点。

服务查询。这个操作会调用注册中心的服务查询接口,可以查询当前注册中心里共注册了多少个服务,每个服务的详细信息。

服务节点查询。这个操作会调用注册中心的节点查询接口,来查询某个服务下一共有多少个节点。

  1. 服务治理

通过微服务治理平台,可以调用配置中心提供的接口,动态地修改各种配置来实现服务的治理。根据我的经验,常用的服务治理手段包括以下几种:

限流。一般是在系统出现故障的时候,比如像微博因为热点突发事件的发生,可能会在短时间内流量翻几倍,超出系统的最大容量。这个时候就需要调用配置中心的接口,去修改非核心服务的限流阈值,从而减少非核心服务的调用,给核心服务留出充足的冗余度。

降级。跟限流一样,降级也是系统出现故障时的应对方案。要么是因为突发流量的到来,导致系统的容量不足,这时可以通过降级一些非核心业务,来增加系统的冗余度;要么是因为某些依赖服务的问题,导致系统被拖慢,这时可以降级对依赖服务的调用,避免被拖死。

切流量。通常为了服务的异地容灾考虑,服务部署在不止一个 IDC 内。当某个 IDC 因为电缆被挖断、机房断电等不可抗力时,需要把故障 IDC 的流量切换到其他正常 IDC,这时候可以调用配置中心的接口,向所有订阅了故障 IDC 服务的消费者下发指令,将流量统统切换到其他正常 IDC,从而避免服务消费者受影响。

  1. 服务监控

微服务治理平台一般包括两个层面的监控。一个是整体监控,比如服务依赖拓扑图,将整个系统内服务间的调用关系和依赖关系进行可视化的展示;一个是具体服务监控,比如服务的 QPS、AvgTime、P999 等监控指标。其中整体监控可以使用服务追踪系统提供的服务依赖拓扑图,而具体服务监控则可以通过 Grafana 等监控系统 UI 来展示。

  1. 问题定位

微服务治理平台实现问题定位,可以从两个方面来进行。一个是宏观层面,即通过服务监控来发觉异常,比如某个服务的平均耗时异常导致调用失败;一个是微观层面,即通过服务追踪来具体定位一次用户请求失败具体是因为服务调用全链路的哪一层导致的。

  1. 日志查询

微服务治理平台可以通过接入类似 ELK 的日志系统,能够实时地查询某个用户的请求的详细信息或者某一类用户请求的数据统计。

  1. 服务运维

微服务治理平台可以调用容器管理平台,来实现常见的运维操作。根据我的经验,服务运维主要包括下面几种操作:

发布部署。当服务有功能变更,需要重新发布部署的时候,可以调用容器管理平台分批按比例进行重新部署,然后发布到线上。

扩缩容。在流量增加或者减少的时候,需要相应地增加或者缩减服务在线上部署的实例,这时候可以调用容器管理平台来扩容或者缩容。

如何搭建微服务治理平台

微服务治理平台之所以能够实现上面所说的功能,关键之处就在于它能够封装对微服务架构内的各个基础设施组件的调用,从而对外提供统一的服务操作 API,而且还提供了可视化的界面,以方便开发人员和运维人员操作。

根据我的经验,一个微服务治理平台的组成主要包括三部分:Web Portal 层、API 层以及数据存储 DB 层,结合下面这张图我来详细讲解下每一层该如何实现。

第一层:Web Portal。也就是微服务治理平台的前端展示层,一般包含以下几个功能界面:

服务管理界面,可以进行节点的操作,比如查询节点、删除节点。

服务治理界面,可以进行服务治理操作,比如切流量、降级等,还可以查看操作记录。

服务监控界面,可以查看服务的详细信息,比如 QPS、AvgTime、耗时分布区间以及 P999 等。

服务运维界面,可以执行服务的扩缩容操作,还可以查看扩缩容的操作历史。

第二层,API。也就是微服务治理平台的后端服务层,这一层对应的需要提供 Web Portal 接口以调用,对应的一般包含下面几个接口功能:

添加服务接口。这个接口会调用注册中心提供的服务添加接口来新发布一个服务。

删除服务接口。这个接口会调用注册中心提供的服务注销接口来下线一个服务。

服务降级 / 限流 / 切流量接口。这几个接口会调用配置中心提供的配置修改接口,来修改对应服务的配置,然后订阅这个服务的消费者就会从配置中心拉取最新的配置,从而实现降级、限流以及流量切换。

服务扩缩容接口。这个接口会调用容器平台提供的扩缩容接口,来实现服务的实例添加和删除。

服务部署接口。这个接口会调用容器平台提供的上线部署接口,来实现服务的线上部署。

第三层,DB。也就是微服务治理平台的数据存储层,因为微服务治理平台不仅需要调用其他组件提供的接口,还需要存储一些基本信息,主要分为以下几种:

用户权限。因为微服务治理平台的功能十分强大,所以要对用户的权限进行管理。一般可以分为可浏览、可更改以及管理员三个权限。而且还需要对可更改的权限进行细分,按照不同服务的负责人进行权限划分,一个人只能对它负责的服务的进行更改操作,而不能修改其他人负责的服务。

操作记录。用来记录下用户在平台上所进行的变更操作,比如降级记录、扩缩容记录、切流量记录等。

元数据。主要是用来把服务在各个系统中对应的记录映射到微服务治理平台中,统一进行管理。比如某个服务在监控系统里可能有个特殊标识,在注册中心里又使用了另外一个标识,为了统一就需要在微服务治理平台统一进行转换,然后进行数据串联。

在落地注册中心的过程中,我们需要解决一系列的问题,包括如何存储服务信息、如何注册节点、如何反注册、如何查询节点信息以及如何订阅服务变更等。

注册中心如何存储服务信息

服务信息除了包含节点信息(IP 和端口号)以外,还包含其他一些信息,比如请求失败时重试的次数、请求结果是否压缩等信息。因此服务信息通常用 JSON 字符串来存储,包含多个字段,每个字段代表不同的含义。

除此之外,服务一般会分成多个不同的分组,每个分组的目的不同。一般来说有下面几种分组方式。

  • 核心与非核心,从业务的核心程度来分。
  • 机房,从机房的维度来分。
  • 线上环境与测试环境,从业务场景维度来区分。

所以注册中心存储的服务信息一般包含三部分内容:分组、服务名以及节点信息,节点信息又包括节点地址和节点其他信息。从注册中心中获取的信息结构大致如下图所示。

具体存储的时候,一般是按照“服务 - 分组 - 节点信息”三层结构来存储,可以用下图来描述。Service 代表服务的具体分组,Cluster 代表服务的接口名,节点信息用 KV 存储。

搞清楚了注册中心存储服务信息的原理后,再来看下注册中心具体是如何工作的,包括四个流程。

  • 服务提供者注册流程。
  • 服务提供者反注册流程。
  • 服务消费者查询流程。
  • 服务消费者订阅变更流程。

注册中心是如何工作的

1. 如何注册节点

服务注册流程:

服务注册流程主要有下面几个步骤:

  • 首先查看要注册的节点是否在白名单内?如果不在就抛出异常,在的话继续下一步。
  • 其次要查看注册的 Cluster(服务的接口名)是否存在?如果不存在就抛出异常,存在的话继续下一步。
  • 然后要检查 Service(服务的分组)是否存在?如果不存在则抛出异常,存在的话继续下一步。
  • 最后将节点信息添加到对应的 Service 和 Cluster 下面的存储中。

2. 如何反注册

服务提供者节点反注册的流程:

节点反注册流程主要包含下面几个步骤:

  • 查看 Service(服务的分组)是否存在,不存在就抛出异常,存在就继续下一步。
  • 查看 Cluster(服务的接口名)是否存在,不存在就抛出异常,存在就继续下一步。
  • 删除存储中 Service 和 Cluster 下对应的节点信息。
  • 更新 Cluster 的 sign 值。

3. 如何查询节点信息

服务消费者从注册中心查询服务提供者的节点信息流程:

服务消费者查询节点信息主要分为下面几个步骤:

首先从 localcache(本机内存)中查找,如果没有就继续下一步。这里为什么服务消费者要把服务信息存在本机内存呢?主要是因为服务节点信息并不总是时刻变化的,并不需要每一次服务调用都要调用注册中心获取最新的节点信息,只需要在本机内存中保留最新的服务提供者的节点列表就可以。

接着从 snapshot(本地快照)中查找,如果没有就继续下一步。这里为什么服务消费者要在本地磁盘存储一份服务提供者的节点信息的快照呢?这是因为服务消费者同注册中心之间的网络不一定总是可靠的,服务消费者重启时,本机内存中还不存在服务提供者的节点信息,如果此时调用注册中心失败,那么服务消费者就拿不到服务节点信息了,也就没法调用了。本地快照就是为了防止这种情况的发生,即使服务消费者重启后请求注册中心失败,依然可以读取本地快照,获取到服务节点信息。

4. 如何订阅服务变更

服务消费者订阅服务提供者的变更信息流程:

主要分为下面几个步骤:

  • 服务消费者从注册中心获取了服务的信息后,就订阅了服务的变化,会在本地保留 Cluster 的sign值。
  • 服务消费者每隔一段时间,调用getSign()函数,从注册中心获取服务端该 Cluster 的sign值,并与本地保留的sign值做对比,如果不一致,就从服务端拉取新的节点信息,并更新localcachesnapshot

注册与发现的几个问题

1. 多注册中心

理论上对于一个服务消费者来说,同一个注册中心交互是最简单的。但是不可避免的是,服务消费者可能订阅了多个服务,多个服务可能是由多个业务部门提供的,而且每个业务部门都有自己的注册中心,提供的服务只在自己的注册中心里有记录。这样的话,就要求服务消费者要具备在启动时,能够从多个注册中心订阅服务的能力。

根据我的经验,还有一种情况是,一个服务提供者提供了某个服务,可能作为静态服务对外提供,有可能又作为动态服务对外提供,这两个服务部署在不同的注册中心,所以要求服务提供者在启动的时候,要能够同时向多个注册中心注册服务。

也就是说,对于服务消费者来说,要能够同时从多个注册中心订阅服务;对于服务提供者来说,要能够同时向多个注册中心注册服务。

2. 并行订阅服务

通常一个服务消费者订阅了不止一个服务,在我经历的一个项目中,一个服务消费者订阅了几十个不同的服务,每个服务都有自己的方法列表以及节点列表。服务消费者在服务启动时,会加载订阅的服务配置,调用注册中心的订阅接口,获取每个服务的节点列表并初始化连接。

最开始我们采用了串行订阅的方式,每订阅一个服务,服务消费者调用一次注册中心的订阅接口,获取这个服务的节点列表并初始化连接,总共需要执行几十次这样的过程。在某些服务节点的初始化连接过程中,出现连接超时的情况,后续所有的服务节点的初始化连接都需要等待它完成,导致服务消费者启动变慢,最后耗费了将近五分钟时间来完成所有服务节点的初始化连接过程。

后来我们改成了并行订阅的方式,每订阅一个服务就单独用一个线程来处理,这样的话即使遇到个别服务节点连接超时,其他服务节点的初始化连接也不受影响,最慢也就是这个服务节点的初始化连接耗费的时间,最终所有服务节点的初始化连接耗时控制在了 30 秒以内。

3. 批量反注册服务

通常一个服务提供者节点提供不止一个服务,所以注册和反注册都需要多次调用注册中心。在与注册中心的多次交互中,可能由于网络抖动、注册中心集群异常等原因,导致个别调用失败。对于注册中心来说,偶发的注册调用失败对服务调用基本没有影响,其结果顶多就是某一个服务少了一个可用的节点。但偶发的反注册调用失败会导致不可用的节点残留在注册中心中,变成“僵尸节点”,但服务消费者端还会把它当成“活节点”,继续发起调用,最终导致调用失败。

以前我们的业务中经常遇到这个问题,需要定时去清理注册中心中的“僵尸节点”。后来我们通过优化反注册逻辑,对于下线机器、节点销毁的场景,通过调用注册中心提供的批量反注册接口,一次调用就可以把该节点上提供的所有服务同时反注册掉,从而避免了“僵尸节点”的出现。

4. 服务变更信息增量更新

服务消费者端启动时,除了会查询订阅服务的可用节点列表做初始化连接,还会订阅服务的变更,每隔一段时间从注册中心获取最新的服务节点信息标记 sign,并与本地保存的 sign 值作比对,如果不一样,就会调用注册中心获取最新的服务节点信息。

一般情况下,按照这个过程是没问题的,但是在网络频繁抖动时,服务提供者上报给注册中心的心跳可能会一会儿失败一会儿成功,这时候注册中心就会频繁更新服务的可用节点信息,导致服务消费者频繁从注册中心拉取最新的服务可用节点信息,严重时可能产生网络风暴,导致注册中心带宽被打满。

为了减少服务消费者从注册中心中拉取的服务可用节点信息的数据量,这个时候可以通过增量更新的方式,注册中心只返回变化的那部分节点信息,尤其在只有少数节点信息变更时,此举可以大大减少服务消费者从注册中心拉取的数据量,从而最大程度避免产生网络风暴。

服务路由就是服务消费者在发起服务调用时,必须根据特定的规则来选择服务节点,从而满足某些特定的需求。

服务路由的应用场景

服务路由主要有以下几种应用场景:

  • 分组调用。一般来讲,为了保证服务的高可用性,实现异地多活的需求,一个服务往往不止部署在一个数据中心,而且出于节省成本等考虑,有些业务可能不仅在私有机房部署,还会采用公有云部署,甚至采用多家公有云部署。服务节点也会按照不同的数据中心分成不同的分组,这时对于服务消费者来说,选择哪一个分组调用,就必须有相应的路由规则。
  • 灰度发布。在服务上线发布的过程中,一般需要先在一小部分规模的服务节点上先发布服务,然后验证功能是否正常。如果正常的话就继续扩大发布范围;如果不正常的话,就需要排查问题,解决问题后继续发布。这个过程就叫作灰度发布,也叫金丝雀部署。
  • 流量切换。在业务线上运行过程中,经常会遇到一些不可抗力因素导致业务故障,比如某个机房的光缆被挖断,或者发生着火等事故导致整个机房的服务都不可用。这个时候就需要按照某个指令,能够把原来调用这个机房服务的流量切换到其他正常的机房。
  • 读写分离。对于大多数互联网业务来说都是读多写少,所以在进行服务部署的时候,可以把读写分开部署,所有写接口可以部署在一起,而读接口部署在另外的节点上。

服务路由的规则

服务路由主要有两种规则:一种是条件路由,一种是脚本路由。

1. 条件路由

条件路由是基于条件表达式的路由规则,以下面的条件路由为例。

1
condition://0.0.0.0/dubbo.test.interfaces.TestService?category=routers&dynamic=true&priority=2&enabled=true&rule=" + URL.encode(" host = 10.20.153.10=> host = 10.20.153.11")

这里面condition://代表了这是一段用条件表达式编写的路由规则,具体的规则是

1
host = 10.20.153.10 => host = 10.20.153.11

分隔符“=>”前面是服务消费者的匹配条件,后面是服务提供者的过滤条件。当服务消费者节点满足匹配条件时,就对该服务消费者执行后面的过滤规则。那么上面这段表达式表达的意义就是 IP 为“10.20.153.10”的服务消费者都调用 IP 为“10.20.153.11”的服务提供者节点。

如果服务消费者的匹配条件为空,就表示对所有的服务消费者应用,就像下面的表达式一样。

1
=> host != 10.20.153.11

如果服务提供者的过滤条件为空,就表示禁止服务消费者访问,就像下面的表达式一样。

1
host = 10.20.153.10=>

下面举一些 Dubbo 框架中的条件路由,来给你讲解下条件路由的具体应用场景。

  • 排除某个服务节点
    1
    => host != 172.22.3.91
    一旦这条路由规则被应用到线上,所有的服务消费者都不会访问 IP 为 172.22.3.91 的服务节点,这种路由规则一般应用在线上流量排除预发布机以及摘除某个故障节点的场景。
  • 白名单和黑名单功能
    1
    host != 10.20.153.10,10.20.153.11 =>
    这条路由规则意思是除了 IP 为 10.20.153.10 和 10.20.153.11 的服务消费者可以发起服务调用以外,其他服务消费者都不可以,主要用于白名单访问逻辑,比如某个后台服务只允许特定的几台机器才可以访问,这样的话可以机器控制访问权限。
    1
    host = 10.20.153.10,10.20.153.11 =>
    同理,这条路由规则意思是除了 IP 为 10.20.153.10 和 10.20.153.11 的服务消费者不能发起服务调用以外,其他服务消费者都可以,也就是实现了黑名单功能,比如线上经常会遇到某些调用方不管是出于有意还是无意的不合理调用,影响了服务的稳定性,这时候可以通过黑名单功能暂时予以封杀。
  • 机房隔离
    1
    host = 172.22.3.* => host = 172.22.3.*
    这条路由规则意思是 IP 网段为 172.22.3.* 的服务消费者,才可以访问同网段的服务节点,这种规则一般应用于服务部署在多个 IDC,理论上同一个 IDC 内的调用性能要比跨 IDC 调用性能要好,应用这个规则是为了实现同 IDC 就近访问。
  • 读写分离
    1
    2
    method = find*,list*,get*,is* => host =172.22.3.94,172.22.3.95
    method != find*,list*,get*,is* => host = 172.22.3.97,172.22.3.98
    这条路由规则意思是find*、get*、is*等读方法调用 IP 为 172.22.3.94 和 172.22.3.95 的节点,除此以外的写方法调用 IP 为 172.22.3.97 和 172.22.3.98 的节点。对于大部分互联网业务来说,往往读请求要远远大于写请求,而写请求的重要性往往要远远高于读请求,所以需要把读写请求进行分离,以避免读请求异常影响到写请求,这时候就可以应用这种规则。

2. 脚本路由

脚本路由是基于脚本语言的路由规则,常用的脚本语言比如 JavaScript、Groovy、JRuby 等。以下面的脚本路由规则为例。

1
"script://0.0.0.0/com.foo.BarService?category=routers&dynamic=false&rule=" + URL.encode("(function route(invokers) { ... } (invokers))")

这里面script://就代表了这是一段脚本语言编写的路由规则,具体规则定义在脚本语言的 route 方法实现里,比如下面这段用 JavaScript 编写的 route() 方法表达的意思是,只有 IP 为 10.20.153.10 的服务消费者可以发起服务调用。

1
2
3
4
5
6
7
8
9
function route(invokers){
var result = new java.util.ArrayList(invokers.size());
for(i =0; i < invokers.size(); i ++){
if("10.20.153.10".equals(invokers.get(i).getUrl().getHost())){
result.add(invokers.get(i));
}
}
return result;
} (invokers));

既然服务路由是通过路由规则来实现的,那么服务消费者该如何获取路由规则呢?

服务路由的获取方式

服务路由的获取方式主要有三种:

  • 本地配置
    顾名思义就是路由规则存储在服务消费者本地上。服务消费者发起调用时,从本地固定位置读取路由规则,然后按照路由规则选取一个服务节点发起调用。
  • 配置中心管理
    这种方式下,所有的服务消费者都从配置中心获取路由规则,由配置中心来统一管理。
  • 动态下发
    这种方式下,一般是运维人员或者开发人员,通过服务治理平台修改路由规则,服务治理平台调用配置中心接口,把修改后的路由规则持久化到配置中心。因为服务消费者订阅了路由规则的变更,于是就会从配置中心获取最新的路由规则,按照最新的路由规则来执行。

上面三种方式实际使用时,还是有一定区别的。

一般来讲,服务路由最好是存储在配置中心中,由配置中心来统一管理。这样的话,所有的服务消费者就不需要在本地管理服务路由,因为大部分的服务消费者并不关心服务路由的问题,或者说也不需要去了解其中的细节。通过配置中心,统一给各个服务消费者下发统一的服务路由,节省了沟通和管理成本。

但也不排除某些服务消费者有特定的需求,需要定制自己的路由规则,这个时候就适合通过本地配置来定制。

而动态下发可以理解为一种高级功能,它能够动态地修改路由规则,在某些业务场景下十分有用。比如某个数据中心存在问题,需要把调用这个数据中心的服务消费者都切换到其他数据中心,这时就可以通过动态下发的方式,向配置中心下发一条路由规则,将所有调用这个数据中心的请求都迁移到别的地方。

当然,这三种方式也可以一起使用,这个时候服务消费者的判断优先级是本地配置 > 动态下发 > 配置中心管理。

总结

服务路由的作用,简单来讲就是为了实现某些调用的特殊需求,比如分组调用、灰度发布、流量切换、读写分离等。在业务规模比较小的时候,可能所有的服务节点都部署在一起,也就不需要服务路由。但随着业务规模的扩大、服务节点增多,尤其是涉及多数据中心部署的情况,把服务节点按照数据中心进行分组,或者按照业务的核心程度进行分组,对提高服务的可用性是十分有用的。以微博业务为例,有的服务不仅进行了核心服务和非核心服务分组,还针对私有云和公有云所处的不同数据中心也进行了分组,这样的话就可以将服务之间的调用尽量都限定在同一个数据中心内部,最大限度避免跨数据中心的网络延迟、抖动等影响。

而服务路由具体是在本地配置,还是在配置中心统一管理,也是视具体业务需求而定的。如果没有定制化的需求,建议把路由规则都放到配置中心中统一存储管理。而动态下发路由规则对于服务治理十分有帮助,当数据中心出现故障的时候,可以实现动态切换流量,还可以摘除一些有故障的服务节点。

什么是密钥

在使用对称密码、公钥密码、消息认证码、数字签名等密码技术都需要一个称为密钥的巨大数字。然而,数字本身的大小并不重要,重要的是密钥空间的大小,也就是可能出现的密钥的总数量,因为密钥空间越大,进行暴力破解就越困难。密钥空间的大小由密钥长度决定。

DES 的密钥

对称密码 DES 的密钥的实质长度为 56 比特(7字节)。一个 DES 密钥可表示为:

1
2
3
4
5
6
// 二进制
01010001 11101100 01001011 00010010 00111101 01000010 00000011
// 十六进制
51 EC 4B 12 3D 42 03
// 十进制
2305928028626269955

3DES 的密钥

在对称密码三重 DES 中,包括使用两个 DES 密钥的 DES-EDE2 和使用三个 DES密钥的 DES-EDE3 两种方式。

DES-EDE2 的密钥的实质长度为 112 比特(14字节)。

1
51 EC 4B 12 3D 42 03 30 04 D8 98 95 93 3F

DES-EDE3 的密钥的实质长度为 168 比特(21字节)。

1
51 EC 4B 12 3D 42 03 30 04 D8 98 95 93 3F 24 9F 61 2A 2F D9 96

AES 的密钥

对称密码 AES 的密钥长度可以从 128、192和256比特中进行选择,当密钥长度为 256 比特时,其长度如下面这个数字:

1
51 EC 4B 12 3D 42 03 30 04 D8 98 95 93 3F 24 9F 61 2A 2F D9 96 B9 42 DC FD A0 AE F4 5D 60 51 F1

各种不同的密钥

对称密码的密钥与公钥密码的密钥

对称密码中,加密和解密使用同一个密钥,密钥必须对发送者和接收者以外的人保密,否则第三方就能解密密文了。

公钥密码中,加密和解密使用的是不同的密钥,用于加密的密钥称为公钥,用于解密的密钥称为私钥。公钥和私钥之间具有深刻的数学关系,因此也称为密钥对。

消息认证码的密钥与数字签名的密钥

消息认证码中,发送者和接收者使用共享的密钥来进行认证。消息认证码只能由持有合法密钥的人计算出来。将消息认证码附加在通信报文后面,就可以识别通信内容是否被篡改或伪装。消息认证码的密钥必须对发送者和接收者之外的人保密,否则就会产生篡改和伪装的风险。

数字签名中,签名的生成和验证使用不同的密钥。只有持有私钥的本人才能够生成签名,验证签名使用的是公钥,任何人都能够验证签名。

用于确保机密性的密钥与用于认证的密钥

对称密码和公钥密码的密钥都是用于确保机密性的密钥,如果不知道用于解密的合法密钥,就无法得知明文的内容。

消息认证码和数字签名所使用的密钥,则是用于认证的密钥。如果不知道合法的密钥,就无法篡改数据,也无法伪装本人的身份。

会话密钥与主密钥

当我们访问以https://开头的网页时,浏览器和服务器之间会进行基于 SSL/TLS 的加密通信。在这样的通信中所使用的密钥是仅限于本次通信的一次性密钥,下次通信时就不能使用了。像这样每次通信只能使用一次的密钥为会话密钥(session key)。

由于会话密钥只在本次通信中有效,万一窃听者获取了本次通信的会话密钥,也只能破译本次通信的内容,下次通信中会使用新的密钥,因此其他通信的机密性不会受到破坏。

相对于每次通信都更换的会话密钥,一直被重复使用的密钥称为主密钥。

用于加密内容的密钥与用于加密密钥的密钥

一般来说,加密的对象是用户直接使用的信息,这种情况下所使用的密钥称为CEK(Contents Encrypting Key,内容加密密钥);用于加密密钥的密钥则称为KEK(Key Encrypting Key,密钥加密密钥)。

在很多情况下,会话密钥都是被作为 CEK 使用的,而主密钥则是被作为 KEK 使用的。

密钥的管理

生成密钥

配送密钥

更新密钥

保存密钥

作废密钥

Diffie-Hellman 密钥交换

基于口令的密码(PBE)

基于口令的密码(Password Based Encryption,PBE)就是一种根据口令生成密钥并用该密钥进行加密的方法。其中加密和解密使用同一个密钥。

PBE 加密

PBE 加密包括 3 个步骤:

  1. 生成 KEK
  2. 生成会话密钥并加密
  3. 加密消息

1.生成 KEK

首先,伪随机数生成器会生成一个被称为盐的随机数。将盐和口令一起输入单向散列函数,得到的散列值就是用来加密密钥的密钥(KEK)。

2.生成会话秘钥并加密

使用伪随机数生成器生成会话密钥,会话密钥是用来加密消息的密钥(CEK)。

会话密钥需要用步骤 1 中生成的 KEK 进行加密,并和盐一起保存在安全的地方。会话密钥加密后,KEK 就会被丢弃,因为 KEK 没有必要保存下来,只要通过盐和口令就可以重建 KEK。

组件注册

组件名

在注册一个组件的时候,我们始终需要给它一个名字。

1
Vue.component('my-component-name', { /* ... */ })

该组件名就是Vue.component的第一个参数。

组件名大小写

定义组件名的方式有两种:

使用短横线分隔命名

1
Vue.component('my-component-name', { /* ... */ })

当使用短横线分隔命名定义一个组件时,必须在引用这个自定义元素时使用短横线分隔命名,例如 <my-component-name>

使用驼峰式命名

1
Vue.component('MyComponentName', { /* ... */ })

当使用驼峰式命名定义一个组件时,引用这个自定义元素时两种命名法都可以使用。也就是说<my-component-name><MyComponentName>都是可接受的。注意,尽管如此,直接在DOM(即非字符串的模板)中使用时只有短横线分隔命名是有效的。

全局注册

1
2
3
Vue.component('my-component-name', {
// ... 选项 ...
})

这些组件是全局注册的。也就是说它们在注册之后可以用在任何新创建的Vue根实例 (new Vue)的模板中。

1
2
3
4
5
6
7
8
Vue.component('component-a', { /* ... */ })
Vue.component('component-b', { /* ... */ })
new Vue({ el: '#app' })

<div id="app">
<component-a></component-a>
<component-b></component-b>
</div>

在所有子组件中也是如此,也就是说这两个组件在各自内部也都可以相互使用。

局部注册

通过一个普通的JavaScript对象来定义组件。

1
2
var ComponentA = { /* ... */ }
var ComponentB = { /* ... */ }

然后在components选项中定义要使用的组件。

1
2
3
4
5
6
7
new Vue({
el: '#app'
components: {
'component-a': ComponentA,
'component-b': ComponentB
}
})

对于components对象中的每个属性来说,其属性名就是自定义元素的名字,其属性值就是这个组件的选项对象。
注意局部注册的组件在其子组件中不可用。例如,如果你希望ComponentAComponentB中可用,则你需要这样写:

1
2
3
4
5
6
7
var ComponentA = { /* ... */ }
var ComponentB = {
components: {
'component-a':ComponentA
},
// ...
}

或者通过Babel和webpack使用ES2015模块。

1
2
3
4
5
6
7
8
import ComponentA from './ComponentA.vue'

export default {
components: {
ComponentA
},
// ...
}

在对象中放一个类似ComponentA的变量名其实是ComponentA: ComponentA的缩写,即这个变量名同时是:

  • 用在模板中的自定义元素的名称
  • 包含了这个组件选项的变量名

模块系统

在模块系统中局部注册

创建一个components目录,并将每个组件放置在其各自的文件中。
然后在局部注册之前导入每个你想使用的组件。例如,在一个假设的ComponentB.jsComponentB.vue文件中:

1
2
3
4
5
6
7
8
9
10
import ComponentA from './ComponentA'
import ComponentC from './ComponentC'

export default {
components: {
ComponentA,
ComponentC
},
// ...
}

现在ComponentAComponentC都可以在ComponentB的模板中使用了。

基础组件的自动化全局注册

可能你的许多组件只是包裹了一个输入框或按钮之类的元素,是相对通用的。我们有时候会把它们称为基础组件,它们会在各个组件中被频繁的用到。
所以会导致很多组件里都会有一个包含基础组件的长列表。

1
2
3
4
5
6
7
8
9
10
11
import BaseButton from './BaseButton.vue'
import BaseIcon from './BaseIcon.vue'
import BaseInput from './BaseInput.vue'

export default {
components: {
BaseButton,
BaseIcon,
BaseInput
}
}

而只是用于模板中的一小部分。

1
2
3
4
<BaseInput v-model="searchText" @keydown.enter="search"/>
<BaseButton @click="search">
<BaseIcon name="search"/>
</BaseButton>

如果你使用了webpack,那么就可以使用require.context只全局注册这些非常通用的基础组件。这里有一份可以让你在应用入口文件 (比如 src/main.js) 中全局导入基础组件的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import Vue from 'vue'
import upperFirst from 'lodash/upperFirst'
import camelCase from 'lodash/camelCase'
const requireComponent = require.context(
'./components', // 其组件目录的相对路径
false, // 是否查询其子目录
// 匹配基础组件文件名的正则表达式
/Base[A-Z]\w+\.(vue|js)$/
)
requireComponent.keys().forEach(fileName => {
// 获取组件配置
const componentConfig = requireComponent(fileName)
// 获取组件的PascalCase命名
const componentName = upperFirst(
camelCase(
// 剥去文件名开头的 `'./` 和结尾的扩展名
fileName.replace(/^\.\/(.*)\.\w+$/, '$1')
)
)
// 全局注册组件
Vue.component(
componentName,
// 如果这个组件选项是通过export default导出的,
// 那么就会优先使用.default,否则回退到使用模块的根。
componentConfig.default || componentConfig
)
})

全局注册的行为必须在根Vue实例(通过new Vue)创建之前发生。

Prop

Prop的大小写

HTML中的特性名是大小写不敏感的,所以浏览器会把所有大写字符解释为小写字符。这意味着当你使用DOM中的模板时,驼峰命名法的prop名需要使用其等价的短横线分隔命名。

1
2
3
4
5
6
7
Vue.component('blog-post', {
// 在JavaScript中是驼峰命名法的
props: ['postTitle'],
template: '<h3>{{ postTitle }}</h3>'
})
<!-- 在 HTML中是短横线分隔命名的 -->
<blog-post post-title="hello!"></blog-post>

如果使用字符串模板,那么这个限制就不存在了。

静态和动态的Prop

可以像这样给prop传入一个静态的值。

1
<blog-post title="My journey with Vue"></blog-post>

prop还可以通过v-bind动态赋值。

1
<blog-post v-bind:title="post.title"></blog-post>

任何类型的值都可以传给一个prop

传入一个数字

1
2
3
4
5
<!-- 即便42是静态的,我们仍然需要v-bind来告诉Vue -->
<!-- 这是一个JavaScript表达式而不是一个字符串 -->
<blog-post v-bind:likes="42"></blog-post>
<!-- 用一个变量进行动态赋值 -->
<blog-post v-bind:likes="post.likes"></blog-post>

传入一个布尔值

1
2
3
4
5
6
7
<!-- 包含该prop没有值的情况在内,都意味着true -->
<blog-post favorited></blog-post>
<!-- 即便false是静态的,我们仍然需要v-bind来告诉Vue -->
<!-- 这是一个JavaScript表达式而不是一个字符串。-->
<base-input v-bind:favorited="false">
<!-- 用一个变量进行动态赋值。-->
<base-input v-bind:favorited="post.currentUserFavorited">

传入一个数组

1
2
3
4
5
<!-- 即便数组是静态的,我们仍然需要v-bind来告诉Vue -->
<!-- 这是一个JavaScript表达式而不是一个字符串 -->
<blog-post v-bind:comment-ids="[234, 266, 273]"></blog-post>
<!-- 用一个变量进行动态赋值 -->
<blog-post v-bind:comment-ids="post.commentIds"></blog-post>

传入一个对象

1
2
3
4
5
<!-- 即便对象是静态的,我们仍然需要v-bind来告诉Vue -->
<!-- 这是一个JavaScript表达式而不是一个字符串 -->
<blog-post v-bind:comments="{ id: 1, title: 'My Journey with Vue' }"></blog-post>
<!-- 用一个变量进行动态赋值 -->
<blog-post v-bind:post="post"></blog-post>

传入一个对象的所有属性

如果你想要将一个对象的所有属性都作为prop传入,可以使用不带参数的v-bind(取代v-bind:prop-name)。

1
2
3
4
5
6
7
8
post: {
id: 1,
title: 'My Journey with Vue'
}
//下面的模板:
<blog-post v-bind="post"></blog-post>
//等价于:
<blog-post v-bind:id="post.id" v-bind:title="post.title"></blog-post>

单向数据流

所有的prop都使得其父子prop之间形成了一个单向下行绑定:父级prop的更新会向下流动到子组件中,但是反过来则不行。这样会防止从子组件意外改变父级组件的状态,从而导致你的应用的数据流向难以理解。
额外的,每次父级组件发生更新时,子组件中所有的prop都将会刷新为最新的值。这意味着你不应该在一个子组件内部改变prop。如果你这样做了,Vue会在浏览器的控制台中发出警告。
这里有两种常见的试图改变一个prop的情形:

  1. 这个prop用来传递一个初始值;这个子组件接下来希望将其作为一个本地的prop数据来使用。在这种情况下,最好定义一个本地的data属性并将这个prop用作其初始值。
    1
    2
    3
    4
    5
    6
    props: ['initialCounter'],
    data: function () {
    return {
    counter: this.initialCounter
    }
    }
  2. 这个prop以一种原始的值传入且需要进行转换。在这种情况下,最好使用这个prop的值来定义一个计算属性。
    1
    2
    3
    4
    5
    6
    props: ['size'],
    computed: {
    normalizedSize: function () {
    return this.size.trim().toLowerCase()
    }
    }
    注意在JavaScript中对象和数组是通过引用传入的,所以对于一个数组或对象类型的prop来说,在子组件中改变这个对象或数组本身将会影响到父组件的状态。

Prop验证

我们可以为组件的prop指定需求。如果有一个需求没有被满足,则Vue会在浏览器控制台中警告你。
为了定制prop的验证方式,可以为props中的值提供一个带有验证需求的对象,而不是一个字符串数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Vue.component('my-component', {
props: {
// 基础的类型检查 (`null` 匹配任何类型)
propA: Number,
// 多个可能的类型
propB: [String, Number],
// 必填的字符串
propC: {
type: String,
required: true
},
// 带有默认值的数字
propD: {
type: Number,
default: 100
},
// 带有默认值的对象
propE: {
type: Object,
// 对象或数组且一定会从一个工厂函数返回默认值
default: function () {
return { message: 'hello' }
}
},
// 自定义验证函数
propF: {
validator: function (value) {
// 这个值必须匹配下列字符串中的一个
return ['success', 'warning', 'danger'].indexOf(value) !== -1
}
}
}
})

prop验证失败的时候,(开发环境构建版本的)Vue将会产生一个控制台的警告。
注意那些prop会在一个组件实例创建之前进行验证,所以实例的属性 (如datacomputed等) 在defaultvalidator函数中是不可用的。

类型检查

type可以是下列原生构造函数中的一个:StringNumberBooleanFunctionObjectArraySymbol
额外的,type还可以是一个自定义的构造函数,并且通过instanceof来进行检查确认。例如,给定下列现成的构造函数:

1
2
3
4
function Person (firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
}

你可以使用:

1
2
3
4
5
Vue.component('blog-post', {
props: {
author: Person
}
})

来验证author prop的值是否是通过new Person创建的。

非Prop的特性

一个非prop特性是指传向一个组件,但是该组件并没有相应prop定义的特性。
因为显式定义的prop适用于向一个子组件传入信息,然而组件库的作者并不总能预见组件会被用于怎样的场景。这也是为什么组件可以接受任意的特性,而这些特性会被添加到这个组件的根元素上。
例如,想象一下你通过一个Bootstrap插件使用了一个第三方的<bootstrap-data-input>组件,这个插件需要在其<input>上用到一个data-date-picker特性。我们可以将这个特性添加到你的组件实例上:

1
<bootstrap-date-input data-date-picker="activated"></bootstrap-date-input>

然后这个data-date-picker="activated"特性就会自动添加到<bootstrap-date-input>的根元素上。

替换/合并已有的特性

想象一下 <bootstrap-date-input> 的模板是这样的:

1
<input type="date" class="form-control">

为了给我们的日期选择器插件定制一个主题,我们可能需要像这样添加一个特别的类名:

1
2
3
4
<bootstrap-date-input
data-date-picker="activated"
class="date-picker-theme-dark"
></bootstrap-date-input>

在这种情况下,我们定义了两个不同的 class 的值:

  • form-control,这是在组件的模板内设置好的
  • date-picker-theme-dark,这是从组件的父级传入的

对于绝大多数特性来说,从外部提供给组件的值会替换掉组件内部设置好的值。所以如果传入 type="text" 就会替换掉 type="date" 并把它破坏!庆幸的是,class 和 style 特性会稍微智能一些,即两边的值会被合并起来,从而得到最终的值:form-control date-picker-theme-dark

禁用特性继承

如果你不希望组件的根元素继承特性,你可以设置在组件的选项中设置inheritAttrs: false。例如:

1
2
3
4
Vue.component('my-component', {
inheritAttrs: false,
// ...
})

这尤其适合配合实例的$attrs属性使用,该属性包含了传递给一个组件的特性名和特性值,例如:

1
2
3
4
{
class: 'username-input',
placeholder: 'Enter your username'
}

有了 inheritAttrs: false 和 $attrs,你就可以手动决定这些特性会被赋予哪个元素。在撰写基础组件的时候是常会用到的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Vue.component('base-input', {
inheritAttrs: false,
props: ['label', 'value'],
template: `
<label>
{{ label }}
<input
v-bind="$attrs"
v-bind:value="value"
v-on:input="$emit('input', $event.target.value)"
>
</label>
`
})

这个模式允许你在使用基础组件的时候更像是使用原始的HTML元素,而不会担心哪个元素是真正的根元素:

1
2
3
4
5
<base-input
v-model="username"
class="username-input"
placeholder="Enter your username"
></base-input>

插槽

插槽内容

Vue实现了一套内容分发的API,将<slot>元素作为承载分发内容的出口。
它允许你像这样合成组件:

1
2
3
<navigation-link url="/profile">
Your Profile
</navigation-link>

然后你在<navigation-link>的模板中可能会写为:

1
2
3
<a v-bind:href="url" class="nav-link">
<slot></slot>
</a>

当组件渲染的时候,这个<slot>元素将会被替换为Your Profile。插槽内可以包含任何模板代码,包括 HTML。

1
2
3
4
5
<navigation-link url="/profile">
<!-- 添加一个 Font Awesome 图标 -->
<span class="fa fa-user"></span>
Your Profile
</navigation-link>

甚至其它的组件。

1
2
3
4
5
<navigation-link url="/profile">
<!-- 添加一个图标的组件 -->
<font-awesome-icon name="user"></font-awesome-icon>
Your Profile
</navigation-link>

如果<navigation-link>没有包含一个<slot>元素,则任何传入它的内容都会被抛弃。

具名插槽

有些时候我们需要多个插槽。例如,一个假设的<base-layout>组件多模板如下:

1
2
3
4
5
6
7
8
9
10
11
<div class="container">
<header>
<!-- 我们希望把页头放这里 -->
</header>
<main>
<!-- 我们希望把主要内容放这里 -->
</main>
<footer>
<!-- 我们希望把页脚放这里 -->
</footer>
</div>

对于这样的情况,<slot>元素有一个特殊的特性:name。这个特性可以用来定义额外的插槽。

1
2
3
4
5
6
7
8
9
10
11
<div class="container">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>

在向具名插槽提供内容的时候,我们可以在一个父组件的<template>元素上使用slot特性。

1
2
3
4
5
6
7
8
9
10
<base-layout>
<template slot="header">
<h1>Here might be a page title</h1>
</template>
<p>A paragraph for the main content.</p>
<p>And another one.</p>
<template slot="footer">
<p>Here's some contact info</p>
</template>
</base-layout>

另一种slot特性的用法是直接用在一个普通的元素上:

1
2
3
4
5
6
<base-layout>
<h1 slot="header">Here might be a page title</h1>
<p>A paragraph for the main content.</p>
<p>And another one.</p>
<p slot="footer">Here's some contact info</p>
</base-layout>

我们还是可以保留一个未命名插槽,这个插槽是默认插槽,也就是说它会作为所有未匹配到插槽的内容的统一出口。上述两个示例渲染出来的HTML都将会是:

1
2
3
4
5
6
7
8
9
10
11
12
<div class="container">
<header>
<h1>Here might be a page title</h1>
</header>
<main>
<p>A paragraph for the main content.</p>
<p>And another one.</p>
</main>
<footer>
<p>Here's some contact info</p>
</footer>
</div>

插槽的默认内容

有的时候为插槽提供默认的内容是很有用的。例如,一个<submit-button>组件可能希望这个按钮的默认内容是Submit,但是同时允许用户覆写为Save、Upload或别的内容。
可以在<slot>标签内部指定默认的内容来做到这一点。

1
2
3
<button type="submit">
<slot>Submit</slot>
</button>

如果父组件为这个插槽提供了内容,则默认的内容会被替换掉。

自定义事件

事件名

跟组件和prop不同,事件名不存在任何自动化的大小写转换。而是触发的事件名需要完全匹配监听这个事件所用的名称。如果触发一个驼峰式命名名字的事件:

1
this.$emit('myEvent')

则监听这个名字的短横线分隔命名版本是不会有任何效果的:

1
<my-component v-on:my-event="doSomething"></my-component>

跟组件和prop不同,事件名不会被用作一个JavaScript变量名或属性名,所以就没有理由使用驼峰式命名了。并且v-on事件监听器在DOM模板中会被自动转换为全小写 (因为HTML是大小写不敏感的),所以v-on:myEvent将会变成v-on:myevent——导致myEvent不可能被监听到。
因此,我们推荐你始终使用短横线分隔命名的事件名。

自定义组件的v-model

一个组件上的v-model默认会利用名为 valueprop和名为input的事件,但是像单选框、复选框等类型的输入控件可能会将value特性用于不同的目的。model选项可以用来避免这样的冲突:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Vue.component('base-checkbox', {
model: {
prop: 'checked',
event: 'change'
},
props: {
checked: Boolean
},
template: `
<input
type="checkbox"
v-bind:checked="checked"
v-on:change="$emit('change', $event.target.checked)"
>
`
})

现在在这个组件上使用v-model的时候:

1
<base-checkbox v-model="lovingVue"></base-checkbox>

这里的lovingVue的值将会传入这个名为checkedprop。同时当<base-checkbox>触发一个change事件并附带一个新的值的时候,这个lovingVue的属性将会被更新。
注意你仍然需要在组件的props选项里声明checked这个prop

将原生事件绑定到组件

要在一个组件的根元素上直接监听一个原生事件。可以使用-on.native修饰符:

1
<base-input v-on:focus.native="onFocus"></base-input>

有的时候这是很有用的,不过在你尝试监听一个类似<input>的非常特定的元素时,这并不是个好主意。比如上述<base-input>组件可能做了如下重构,所以根元素实际上是一个<label>元素:

1
2
3
4
5
6
7
8
<label>
{{ label }}
<input
v-bind="$attrs"
v-bind:value="value"
v-on:input="$emit('input', $event.target.value)"
>
</label>

这时,父级的.native监听器将静默失败。它不会产生任何报错,但是onFocus处理函数不会被调用。
为了解决这个问题,Vue提供了一个$listeners属性,它是一个对象,里面包含了作用在这个组件上的所有监听器。

1
2
3
4
{
focus: function (event) { /* ... */ }
input: function (value) { /* ... */ },
}

有了这个$listeners属性,你就可以配合v-on="$listeners"将所有的事件监听器指向这个组件的某个特定的子元素。对于类似<input>的你希望它也可以配合v-model工作的组件来说,为这些监听器创建一个类似下述inputListeners的计算属性通常是非常有用的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Vue.component('base-input', {
inheritAttrs: false,
props: ['label', 'value'],
computed: {
inputListeners: function () {
var vm = this
// `Object.assign` 将所有的对象合并为一个新对象
return Object.assign({},
// 我们从父级添加所有的监听器
this.$listeners,
// 然后我们添加自定义监听器,
// 或覆写一些监听器的行为
{
// 这里确保组件配合 `v-model` 的工作
input: function (event) {
vm.$emit('input', event.target.value)
}
}
)
}
},
template: `
<label>
{{ label }}
<input
v-bind="$attrs"
v-bind:value="value"
v-on="inputListeners"
>
</label>
`
})

现在<base-input>组件是一个完全透明的包裹器了,也就是说它可以完全像一个普通的<input>元素一样使用了:所有跟它相同的特性和监听器的都可以工作。

.sync修饰符

在有些情况下,我们可能需要对一个prop进行“双向绑定”。不幸的是,真正的双向绑定会带来维护上的问题,因为子组件可以修改父组件,且在父组件和子组件都没有明显的改动来源。
我们推荐以update:my-prop-name的模式触发事件取而代之。举个例子,在一个包含title prop的假设的组件中,我们可以用以下方法表达对其赋新值的意图:

1
this.$emit('update:title', newTitle)

然后父组件可以监听那个事件并根据需要更新一个本地的数据属性。例如:

1
2
3
4
<text-document
v-bind:title="doc.title"
v-on:update:title="doc.title = $event"
></text-document>

为了方便起见,我们为这种模式提供一个缩写,即 .sync 修饰符:

1
<text-document v-bind:title.sync="doc.title"></text-document>

当我们用一个对象同时设置多个prop的时候,也可以将这个 .sync 修饰符和 v-bind 配合使用:

1
<text-document v-bind.sync="doc"></text-document>

这样会把doc对象中的每一个属性 (如title) 都作为一个独立的prop传进去,然后各自添加用于更新的v-on监听器。
v-bind.sync用在一个字面量的对象上,例如v-bind.sync=”{ title: doc.title }”,是无法正常工作的,因为在解析一个像这样的复杂表达式的时候,有很多边缘情况需要考虑。

避免v-if和v-for一起使用

为什么要避免v-ifv-for在同一个元素上同时使用呢?因为在 vue 的源码中有一段代码时对指令的优先级的处理,这段代码是先处理v-for再处理v-if的。所以如果我们在同一层中一起使用两个指令,会出现一些不必要的性能问题,比如这个列表有一百条数据,再某种情况下,它们都不需要显示,当 vue 还是会循环这个 100 条数据显示,再去判断v-if,因此,我们应该避免这种情况的出现。

1
2
3
4
5
6
7
<!-- 不好的🌰 -->
<h3 v-if="status" v-for="item in 100" :key="item">{{item}}</h3>

<!-- 好的🌰 -->
<template v-if="status" >
<h3 v-for="item in 100" :key="item">{{item}}</h3>
</template>

1.蓝牙打印

效果图

问题

暂无接口

2.首页列表接口

相关接口

1
https://www.zjcoldcloud.com/xiandun/public/index.php/index/device/device_list?openid=owUdO5al4WDF6rHgqfzkTsCYNlTY&offset=0

接口返回参数如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
{
"code": 0,
"message": "success",
"data": {
"data": [
{
"guigexinghao": "ZL-TH10TP",
"daoqishijian": "2021-06-23 00:00:00",
"beizhu": "",
"id": 35856,
"zhiding": 0,
"shebeibianhao": "500317",
"last_time": "2019-11-02 23:57:39",
"last_servicetime": "2019-11-02 23:58:03",
"last_temperature01": 28.8,
"last_temperature02": 0,
"last_humidity": 47.4,
"last_jingdu": 113.53752136,
"last_weidu": 23.14853668,
"last_power": 5,
"last_yuliu01": 204,
"last_yuliu02": 157,
"last_shujuchangdu": 0,
"hegewendu_xiaxian": 24,
"hegewendu_shangxian": 26,
"baojingwendu_xiaxian": 24,
"baojingwendu_shangxian": 27,
"model_type": "TH",
"is_master": 1,
"jihuoshiian": "2020-06-23 10:58:03",
"address": "广东省广州市萝岗区云埔一路16-18号广州达意隆包装机械股份有限公司西87米",
"hegewenduqujian": "24-26℃",
"baojingwenduqujian": "24-27℃",
"xiangzistate": "close",
"xinhaoqiangdu": 29
}
],
"count": 1
}
}

问题

缺少温湿度报警阈值、温湿度超温报警开关、在线离线、电量下线.
相关字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 温度1、2
baojingwendu_shangxian_baojing
baojingwendu_xiaxian_baojing
baojingwendu_shangxian
baojingwendu_xiaxian
baojingwendu_two_xiaxian_baojing
baojingwendu_two_shangxian_baojing
baojingwendu_two_xiaxian
baojingwendu_two_shangxian
// 湿度
chaodishidubaojing
chaogaoshidubaojing
chaodishidubaojingfazhi
chaogaoshidubaojingfazhi
// 电量
dianliang_xiaxian_baojing
dianliang_xiaxian

3.设备参数设置

相关接口

1
https://www.zjcoldcloud.com/xiandun/public/index.php/index/device/update_device

相关字段

1
2
yejianshangchuankaiguan
dingshifasong

参数效果图

问题

定时推送时间添加能成功,删除不成功,接口返回success
夜间上传开关设置不成功,接口返回success

  • Copyrights © 2017-2023 WSQ
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信