Fork me on GitHub

IPv6详解

2019 年 11 月 25 日已分配完公网 IPv4 地址,以后就没有多余地址可以分配了。短期内可以使用 NAT 技术进行缓解。长期来看,还是要用 128 位的 IPv6 地址替代 32 位的 IPv4 地址,IPv6 可以满足未来 IP 地址的需求。

IPv4地址数量:232
IPv6地址数量:2128 = 3.4 * 1038

IPv6 地址

IPv6 地址表示

IPv6 地址使用十六进制表示法,分隔成 8 个 16 位段,每 16 位段的值在0000~FFFF的十六进制数之间,每个 16 位段之间用:分开。

1
2001:1111:0100:000a:0000:00bc:2500:0a0b

为了方便理解,可以查看下面的进制转换表。

二进制 十进制 十六进制
0000 0 0
0001 1 1
0010 2 2
0010 3 3
0010 4 4
0010 5 5
0010 6 6
0010 7 7
0010 8 8
0010 9 9
0010 10 A
0010 11 B
0010 12 C
0010 13 D
0010 14 E
0010 15 F

但是 IPv6 地址还是太长,不方便记忆,也不方便书写,毫无规律可言。于是就有了两条简化规则。第一条规则是:

每组十六进制数中开头的 0 可以省略。

上面的 IPv6 地址可以写成:2001:1111:100:a:0:bc:2500:a0b

这里需要注意,开头的 0 才能省略,末尾的 0 是不能省略的,因为这样会引起歧义,无法确定省略的 0 是在数字前还是数字后。

如果有个 IPv6 地址有一串的 0,比如:

1
2001:0000:0000:0000:0000:0000:0000:0003

可以简写成:2001:0:0:0:0:0:0:3

这时,还可以使用第二个规则进行简化,第二条规则是:

由全 0 组成的连续的 16 位段可以用一对冒号::表示。

上面的地址还可以简化成:2001::3

这里需要注意,一个 IPv6 地址内,只能使用一次::表示。如果使用两次及以上,也会产生歧义。举个栗子:

1
2001:0a0c:0000:0000:0021:0000:0000:0077

正确的写法有是:

1
2
2001:a0c::21:0:0:77
2001:a0c:0:0:21::77

如果使用了两次::,那么就是错误的:2001:a0c::21::77

有两个全 0 字符串,就无法确定它们的长度,上面错误的地址会有几种可能:

1
2
3
2001:0a0c:0000:0021:0000:0000:0000:0077
2001:0a0c:0000:0000:0021:0000:0000:0077
2001:0a0c:0000:0000:0000:0021:0000:0077

IPv4 的网段地址可以用子网掩码表示,还可以用斜线法表示。IPv6 只能用斜线法表示网段地址,即在 IPv6 地址后面加上一个斜线/,后面加上一个十进制的数字,来表示前面多少位是网络位。网络位是 64 位的 IPv6 地址表示如下:

1
3001:2222:333:aa:bc::707:9900/64

对应的网段地址是:3001:2222:333:aa::/64

全是 0 的 IPv6 地址可以写成一对冒号。当网络位是 0 位时,表示默认地址。

1
::/0

当网络位是 128 位时,表示未指定地址。设备未分配 IPv6 地址时,就用未指定地址作为标识进行报文交互。

1
::/128

IPv6 地址类型

IPv6 地址根据使用范围和功能,分为三种类型:单播、任意播、组播。

对比 IPv4,IPv6 地址中没有广播地址,但是有一个包含全部节点的组播地址,跟 IPv4 中的广播地址功能相同。

其中单播地址又细分为全球单播地址、唯一本地地址和链路本地地址等。

全球单播地址

单播地址表示单台设备的地址。全球单播地址是指这个单播地址是全球唯一的。也就是说,全球单播地址是可以在公网使用、全网可路由的 IPv6 地址,类似于 IPv4 的公网 IP 地址。全球单播 IPv6 地址是由 Internet 地址授权委员会(IANA)分配给地区 Internet 注册机构(RIR),再由 RIR 分配给 Internet 服务提供商(ISP)。

IANA 分配 128 位的 IPv6 地址时,同 IPv4 一样,也是分配一个网段,即网络/子网位,不会分配 128 位的地址。IPv6 单播地址的通用格式如下:

全球单播 IPv6 地址的前 3 位固定为 001;第4~48位的这 45 位由地址分配机构分配;48 位之后的 16 位是网络划分子网位,称为子网 ID;剩余的 64 位 IPv6 地址就是主机位,但是叫做接口 ID(Interface ID)。因为一台主机可以有几个接口,用 IPv6 地址表示主机的一个接口更准确,而不是表示一台主机。同时,一个接口可以有多个 IPv6 地址,还可以有一个 IPv4 地址,接口 ID 只是这个接口的几个标识符之一。

通常,全球 IPv6 地址的接口 ID 是 64 位,子网 ID 是 16 位。一个 16 位的子网 ID 可以划分 65536 个不同的子网。很少有这么多子网的网络,因此全球单播 IPv6 地址还有另外一种格式:前缀是 n 位,子网 ID 是64-n位,接口 ID 也是 64 位。两种格式也不是矛盾的。

将全球单播 IPv6 地址的前 3 位固定值转换为 IPv6 表示法,可知全球单播地址的前缀为2000::/3

IANA 和 RIR 把长度/32/35的 IPv6 前缀分配给本地 Internet 注册机构(LIR)。LIR 通常是大型的 ISP,LIR 分配前缀长度/48的 IPv6 地址给各个客户。也有一些例外,会分配不同长度的前缀:

  • 如果一个客户非常庞大,那么可以分配一个长度小于/48的前缀。
  • 如果有且仅有一个子网需要地址,那么可以分配一个长度是/64的前缀。
  • 如果有且仅有一台设备需要地址,那么可以分配一个长度是/128的前缀。

IPv6 地址类型

IPv6 地址开头的二进制标识地址类型。比如:全球单播地址的前 3 位是 001。

地址类型 格式前缀(二进制) IPv6前缀
未指定地址 00…0 ::/128
回环地址 00…1 ::1/128
链路本地地址 1111111010 FE80::/10
唯一本地地址 11111101 FD00::/8
全球单播地址 其它 其它
组播地址 11111111 FF00::/8
任播地址 其它 其它

本地单播地址

除了全球单播地址,还有几种其它类型的本地单播地址,分别应用在不同的场景。

本地单播地址有 4 种类型,分别是唯一本地地址、链路本地地址、未指定地址、回环地址。

唯一本地地址

虽然 IPv6 地址非常充足,但是 IANA 还是分配了一段可以在私有网络使用的私有 IP 地址空间。这种可以自行使用而不用申请的单播 IPv6 地址叫做唯一本地地址。唯一本地地址只能在私有网络使用,不能在全球路由,不同的私网可以复用这类地址。它的作用和范围跟 IPv4 的私有 IP 地址相同。

唯一本地地址的前 7 为固定是1111110,前缀为FC00::/7的 IPv6 地址。之前还有站点本地地址(Site Local Address),前缀是FEC0::/10,已被 ULA 取代。

唯一本地地址的第 8 位比较特殊。第 8 位为 0 时,未定义,也就是说,FC00::/8这个 IPv6 地址前缀属于保留的地址空间。目前私有网络使用的 IPv6 地址是以11111101开头的,即前缀为FD00::/8的 IPv6 地址。

链路本地地址

IPv6 的链路本地地址(Link-Local Address),是 IPv4 地址中没有的类型,是 IPv6 新定义的地址类型。

链路本地地址是只在链路内有效的地址。启动 IPv6 时,网络接口会自动配置这样的一个 IPv6 地址,就可以直接和同一链路上的其它设备通信。因为链路本地地址只在链路本地有效,所以这些数据包不会被发送到其它链路上。

链路本地地址的前 10 位固定是1111111010,之后的 54 位固定为 0,最后 64 位是接口 ID。也就是说,链路本地地址的前缀为FE80::/10

如果链路本地地址的前 64 位都是相同的,那么接口如何使用 64 位的接口 ID 进行标识,才能确保链路本地地址在链路中不会出现 IP 地址冲突呢?答案是接口使用自己的物理 MAC 地址来填充接口 ID 字段。理论上接口的 MAC 地址是唯一的,因此通过 MAC 地址生成的接口 ID 和链路本地地址也是唯一的。

把 MAC 地址转换成接口 ID,使用 MAC-to-EUI64 转换法。简单的讲,就是使用接口的 48 位 MAC 地址,在 MAC 地址中间,也就是 OUI 后面,插入一个固定的十六进制数0xFFFE,并把第 7 位的 U/L (全局/本地)位设置为 1,这样就转换为一个 64 位的接口 ID。

未指定地址

未指定地址是 128 位全为 0 的前缀地址,简写成::/128,相当于 IPv4 中的0.0.0.0/32。这个地址不能分配给接口使用,只有当 IPv6 设备还没获取到地址时,才将未指定地址作为数据包的源 IPv6 地址。

回环地址

回环地址是前 127 位全为 0,最后一位是 1 的 128 位前缀地址,简写成::1/128,相当于 IPv4 中的回环地址127.0.0.1/8。回环地址表示节点自己,不能分配给接口使用。只要设备的协议栈状态正常,设备就可以收到发送给回环地址的数据包。

任意播地址

IPv6 定义了一种任性的功能,通过任意播地址实现。任意播地址是根据功能定义的,而不是根据报文格式,IPv6 没有定义任意播的地址空间,与单播使用相同的地址空间。所以,无法根据地址判断是单播地址还是任意播地址。

单播是一对一,组播是一对多,广播是一对全体,那么任意播就是一对最近的通信方式。

一个任意播地址可以分配给多台设备,路由器会有多条路由到达相同的目的地,选择代价最小的路由进行数据转发。在大型网络中,流量可以发送到最近的设备,数据传输效率更高。而且当最近的设备故障时,路由器可以把路由指向下一台最近的路由器。

任意播地址不能用作源地址,只能作为目的地址。不能指定给 IPv6 主机,只能指定给 IPv6 路由器。

组播地址

组播地址不是标识一台设备,而是一组设备:一个组播组(Multicast Group)。发送组播数据包通常是单台设备,可以是组播组成员,也可以是其它主机,数据包的目的地址是组播地址。

组播组成员有可能是一台设备,也可能是这个网络上的所有设备。IPv6 没有广播地址,但是有一个包含所有节点的组播组,和广播地址做相同的事情:所有节点都是这个组播组的成员。

组播地址的前 8 位全是 1,后面跟着 4 位标记位,再后面就是 4 位表示地址范围。最后的 112 位作为组 ID (Group ID),标识不同的组播组。前面的 80 位是 0,只使用后面的 32 位。

4 位标记位中,第 1 位是保留标记位,未使用,使用固定值 0。第 2 位用于汇集点(Rendezvous Point),汇集点是组播的一个概念,叫做 R 位,通常取值为 0。第 3 位表示组播地址是否带了前缀,叫做 P 位。组播地址没前缀,取值为 0。大多数情况是 0。最后一位是 T 位,值为 0 时表示是已定义的、永久的组播地址;值为 1 时是临时充当一些设备的组播组。因此,各个协议使用的组播组是以 FF0 开头的 IPv6 地址,而自定义的组播组是以 FF1 开头的。

组播地址和单播地址一样,有一个有效范围,4 为范围位定义了组播地址的使用范围。不同取值的范围表如下:

范围位值 表示范围
1 接口本地
2 链路本地
3 子网本地
4 管理范围本地
5 站点本地
8 组织机构本地
E 全局

常见的 IPv6 组播地址的格式是标记位的值是 0,范围位的值是 2,即前缀为 FF02 的组播地址。

地址 组播组
FF02::1 所有节点
FF02::1 所有路由器
FF02::1 OSPFv3路由器
FF02::1 OSPFv3指定路由器
FF02::1 RIP路由器
FF02::1 DHCP服务器/中继代理

嵌入的 IPv4 地址

在 IPv6 地址的环境中使用 IPv4 地址,需要用到转换技术,把 IPv4 地址转换成 IPv6 地址。比如 6to4 技术就是将 IPv4 地址转换成 16 进制数,再嵌入到 IPv6 地址的最后 32 位。

IPv6 数据报

IPv6 仍支持无连接的传送,但将协议数据单元 PDU 称为分组。

所引进的主要变化如下:

  • 更大的地址空间。IPv6 将地址从 IPv4 的 32 位增大到了 128 位。
  • 扩展的地址层次结构。
  • 灵活的首部格式。IPv6 定义了许多可选的扩展首部。
  • 改进的选项。IPv6 允许数据报包含有选项的控制信息,其选项放在有效载荷中。
  • 允许协议继续扩充。
  • 支持即插即用(即自动配置)。因此 IPv6 不需要使用 DHCP。
  • 支持资源的预分配。IPv6 支持实时视像等要求,保证一定的带宽和时延的应用。

IPv6 首部改为 8 字节对齐。首部长度必须是 8 字节的整数倍。原来的 IPv4 首部是 4 字节对齐。

IPv6 数据报的一般形式

IPv6 数据报由两大部分组成:

  • 基本首部
  • 有效载荷(payload)。有效载荷也称为净负荷。有效载荷允许有零个或多个扩展首部(extension header),再后面是数据部分。

IPv6 数据报的基本首部

IPv6 将首部长度变为固定的 40 字节,称为基本首部。

把首部中不必要的功能取消了,使得 IPv6 首部的字段数减少到只有 8 个。

IPv6 对首部中的某些字段进行了如下的更改:

  • 版本(version)—— 4 位。它指明了协议的版本,对 IPv6 该字段总是 6。
  • 通信量类(traffic class)—— 8 位。这是为了区分不同的 IPv6 数据报的类别或优先级。目前正在进行不同的通信量类性能的实验。
  • 流标号(flow label)—— 20 位。 “流”是互联网络上从特定源点到特定终点的一系列数据报, “流”所经过的路径上的路由器都保证指明的服务质量。所有属于同一个流的数据报都具有同样的流标号。
  • 有效载荷长度(payload length)—— 16 位。它指明 IPv6 数据报除基本首部以外的字节数(所有扩展首部都算在有效载荷之内),其最大值是 64 KB。
  • 下一个首部(next header)—— 8 位。它相当于 IPv4 的协议字段或可选字段。
  • 跳数限制(hop limit)—— 8 位。源站在数据报发出时即设定跳数限制。路由器在转发数据报时将跳数限制字段中的值减 1。当跳数限制的值为零时,就要将此数据报丢弃。
  • 源地址—— 128 位。数据报的发送站的 IP 地址。
  • 目的地址—— 128 位。数据报的接收站的 IP 地址。

IPv6 的扩展首部

IPv6 把原来 IPv4 首部中选项的功能都放在扩展首部中,并将扩展首部留给路径两端的源站和目的站的主机来处理。

数据报途中经过的路由器都不处理这些扩展首部(只有一个首部例外,即逐跳选项扩展首部)。

这样就大大提高了路由器的处理效率。

在 RFC 中定义了六种扩展首部:逐跳选项、路由选择、分片、鉴别、封装安全有效载荷、目的站选项。

从 IPv4 向 IPv6 过渡

向 IPv6 过渡只能采用逐步演进的办法,同时,还必须使新安装的 IPv6 系统能够向后兼容:IPv6 系统必须能够接收和转发 IPv4 分组,并且能够为 IPv4 分组选择路由。

两种向 IPv6 过渡的策略:使用双协议栈、使用隧道技术。

双协议栈

双协议栈是指在完全过渡到 IPv6 之前,使一部分主机(或路由器)装有两个协议栈,一个 IPv4 和一个 IPv6。

双协议栈的主机(或路由器)记为 IPv6/IPv4,表明它同时具有两种 IP 地址:一个 IPv6 地址和一个 IPv4 地址。

双协议栈主机在和 IPv6 主机通信时是采用 IPv6 地址,而和 IPv4 主机通信时就采用 IPv4 地址。

根据 DNS 返回的地址类型可以确定使用 IPv4 地址还是 IPv6 地址。

隧道技术

在 IPv6 数据报要进入 IPv4 网络时,把 IPv6 数据报封装成为 IPv4 数据报,整个的 IPv6 数据报变成了 IPv4 数据报的数据部分。

当 IPv4 数据报离开 IPv4 网络中的隧道时,再把数据部分(即原来的 IPv6 数据报)交给主机的 IPv6 协议栈。

要使双协议栈的主机知道 IPv4 数据报里面封装的数据是一个 IPv6 数据报,就必须把 IPv4 首部的协议字段的值设置为41,41表示数据报的数据部分是 IPv6 数据报。

ICMPv6

IPv6 也不保证数据报的可靠交付,因为互联网中的路由器可能会丢弃数据报。因此 IPv6 也需要使用 ICMP 来实现错误检查和报告机制功能。新的版本称为 ICMPv6。

IPv4 协议中 ICMP 使用的协议号是 1,而 IPv6 协议中 ICMPv6 使用的值是 58。ICMPv6 对于头部字段的定义也与 ICMP 相同。

地址解析协议 ARP 和网际组管理协议 IGMP 协议的功能都已被合并到 ICMPv6 中。

ping 功能也是使用 Echo 请求和 Echo 应答报文。除此之外,还有一个基于 ICMP 的新协议:邻居发现协议。

ICMPv6 报文的分类

ICMPv6 是面向报文的协议,它利用报文来报告差错,获取信息,探测邻站或管理多播通信。

ICMPv6 还增加了几个定义报文的功能及含义的其他协议。

NDP

IPv6 的邻居发现协议(NDP)相当于 IPv4 的 ARP、ICMP 的路由器发现和 ICMP 的重定向,还可以发现网络中使用的 IPv6 地址前缀等参数,并实现地址自动配置等。IPv6 协议通过 NDP 功能实现即插即用特性:

  • 路由器发现(Router Discovery):当一个节点接入到 IPv6 链路时,它可以发现链路上的路由器,而不需要借助使用 DHCP。
  • 前缀发现(Prefix Discovery):当一个节点接入到 IPv6 链路时,它能够发现链路的前缀。
  • 参数发现(Parameter Discovery):节点能够发现所在链路的参数,像链路的 MTU 和跳数限制等。
  • 地址自动配置(Address Autoconfiguration):节点能够自动配置,不需要使用 DHCP。
  • 地址解析(Address Resolution):节点不需要通过 ARP 就能够获取链路上其它节点的 MAC 地址。
  • 下一跳确定(Next-Hop Determination):能够确定到达目的节点的下一跳链路层节点,或者所在链路的目的节点,或是到达目的节点的路由器。
  • 邻居不可达检测(Neighbor Unreachability Detection):节点上能够检测到链路上的邻居何时不可达,邻居有可能是主机,也可能是路由器。
  • 地址冲突检测(Duplicate Address Detection):节点能够检测到要使用的地址是否已经被其它节点占用。
  • 重定向(Redirect):对于非连接的目的节点,路由器能够通知主机存在更好的下一跳路由。

NDP 报文是在数据链路内接收和发送,因此封装 NDP 的数据包是使用 IPv6 链路本地地址,或者是链路范围内的组播地址。在安全性上也有加强,NDP 报文的跳数限制是 255。如果收到的数据包的跳数限制值小于 255,那么这个数据包至少经过了一台路由器,因此丢弃这个数据包。这样可以阻止 NDP 不会受到非本地链路的攻击或欺骗。

NDP 报文

NDP 定义了 5 种报文类型,且跳数限制字段值都是 255。如果收到的 NDP 报文中跳数限制字段值不是 255,那么会丢弃这个 NDP 报文。在 ICMPv6 封装这 5 种 NDP 报文时,编码字段都是 0,不同报文类型通过类型值来标识:

  • 路由器请求(Router Solicitation,RS):路由器请求报文是由主机发出的,用来请求链路中的路由器发送一个 RA。类型字段值是 133。
  • 路由器通告(Router Advertisement,RA):路由器通告报文是路由器发出的,用来通告路由器的存在和链路参数,比如:链路前缀、链路 MTU ,以及跳数限制等。这些报文周期性的发送,也用于响应路由器请求报文。类型字段值是 134。
  • 邻居请求(Neighbor Solicitation,NS):也是有主机发起,用来请求另一台主机的 MAC 地址,也用于地址冲突检测、邻居不可达检测。类型字段值是 135。
  • 邻居通告(Neighbor Advertisement,NA):用于响应邻居请求报文。如果一个节点改变了 MAC 地址,那么它通过发送一个未请求的邻居通告报文来告知这个新地址。类型字段值是 136。
  • 重定向(Redirect):跟 IPv4 协议中的 ICMP 用法相同,只不过是移植到 NDP 中。类型字段值是 137。

路由器发现

路由器在所在的链路上周期性发送 RA,告知它的存在和配置的所有参数。未收到请求的 RA 的源地址是路由器接口的链路本地 IPv6 地址,目的地址是所有节点的组播地址(FF02::1)。

刚接入到链路的主机,需要等待一个 RA,用来发现链路上的路由器和链路参数。默认等待 200 秒的时间太长。所以,主机激活时,就会发送一个 RS,这个报文的源地址可以是未指定地址(::),也可以是主机的链路本地 IPv6 地址。目的地址就是所有路由器的组播地址(FF02::2),请求链路本地路由器为主机提供一些信息。

只有路由器才会监听链路本地路由器组播地址,当路由器收到 RS 时,就会发送一条 RA 作为响应。如果收到报文的源地址是链路本地地址,那么使用链路本地地址单播发送。如果源地址是未指定地址(::),那么会以组播方式发送给所有节点(FF02::1)。

当主机收到 RS 时,会把路由器的链路本地地址作为默认路由地址,添加到自己的路由表中。如果路由器列表有多条默认路由器条目,那么主机要给出选定默认路由器的方法。要么是整个默认路由器列表依次轮询,要么选择单台路由器作为默认路由。

地址自动配置

当一台 IPv6 的设备第一次接入链路时,它能够自动配置自己的接口地址。这个过程的第一步就是确定 64 位接口 ID 部分,使用 MAC-to-EUI64 转换法获取接口 ID。

当然,接口 ID 只是 IPv6 地址的一半,还需要一个 64 位的前缀。前面提到过,链路本地前缀是0xFF80::/10。用它作为 64 位前缀(0xFF80::/64),再加上转换后的接口 ID,就是一个完整的 IPv6 地址,可以和同一链路上设备进行通信。

如果一台主机只需要和所在链路上的设备通信,那么它自动配置的链路本地地址就已经满足了。但是如果主机需要和链路之外的设备通信,那么它就需要一个更大范围的地址,通常是一个全球 IPv6 地址。有两种途径获取这类地址:有状态或无状态的地址自动配置。

使用 DHCPv6 服务器来分配 IPv6 地址,称为有状态地址自动配置。主机要么根据预先的配置查找 DHCPv6 服务器,要么收到字段 M 置位的路由器通告报文来获取 DHCPv6 服务器。

更有趣的是无状态地址自动配置(Stateless Address Autoconfiguration ,SLAAC),不依赖服务器、不需要手动配置。这个过程非常简单,当一台 IPv6 设备接入网络时,会发送 RS 来查询网络中是否存在路由器。RA 有一个字段可以告诉 IPv6 设备使用哪种方式配置自己的 IPv6 地址,这个字段称为 M 位。如果 M 位置位,值为 1 时,表示设备通过 DHCPv6 协议动态配置 IPv6 地址;如果 M 位不置位,值为 0 时,则表示设备通过 SLAAC 来配置 IPv6 地址。

IPv6 设备从收到的 RA 中获取一个或多个链路前缀,再加上之前确定的接口 ID,就得到了一个全球唯一的 IPv6 地址。

IPv6 设备执行 SLAAC 的过程,不需要人工干预,也没有 DHCP 服务器参与,设备自行完成配置。也就是说,这种机制为 IPv6 网络提供了即插即用功能。

邻居地址解析

IPv4 通过 ARP 获取 MAC 地址,然而 ARP 协议无法照搬到 IPv6 环境中,IPv6 没有定义广播地址。为了解决查询目的设备的 MAC 地址问题, IPv6 通过 NDP 获取 MAC 地址。IPv6 设备使用 NS 和 NA 来实现 MAC 地址的查询和响应。IPv6 使用目的节点组播地址作为 NS 的目的地址。

目的节点的组播地址的前 104 位固定是FF02::1:FF,后 24 位使用目的单播 IPv6 地址接口 ID 的后 24 位。当接口获取一个单播或任意播 IPv6 地址时,就会同时监听发送给这个单播地址对于的目的节点组播地址。

如果目的节点是链路之外的节点,那么可以通过路由器通告报文,获取默认路由器的 MAC 地址。如果目的节点在链路内,那么节点会先查找邻居缓存看一下是否已经学到这个地址。IPv6 的邻居缓存和 IPv4 的 ARP 缓存相似,记录 IP 地址和 MAC 地址的对应关系。

如果地址不在邻居缓存中,节点会发送一个 NS。目的节点收到报文后,就知道源节点的 MAC 地址,并回复邻居通告报文。

如果目的节点存在并且收到了 NS,那么它会回复一个 NA。这个 NA 的目的地址就是源节点的源地址。收到响应的 NA 后,源节点就把目的节点的 MAC 地址添加到邻居缓存的条目中。

NDP 的 NA 还有另一种用法,当 IPv6 节点的 MAC 地址发生变化时,也可以在未收到 NS 的情况下,直接向本地链路发生一条 NA,向本地链路上其它设备通告新的 IPv6 地址和 MAC 地址的对应关系。因为目的是通告给链路中所有设备,而不是某一台设备,所有 NA 的目的地址就是链路本地所有节点组播地址(FF02::1)。

地址冲突检测

虽然通过 MAC 地址转换成接口 ID,大多数情况下可以保证是设备地址是唯一的,但是也可能存在重复 MAC 地址的情况,因此不管设备是如何获取地址的,都需要在使用之前进行地址冲突检测。

获取一个地址的节点会把新地址作为临时状态的地址。在地址冲突检测完成前,地址不能被使用。节点会发送目的地址是新地址的 NS 来验证。NS 的源地址是未指定地址,目的地址是目的节点的组播地址。

如果节点收到一个 NS,并且目的地址是这个节点已经使用的地址,就会发送一个目的地址为已使用地址的 NA。源节点收到 NA 后,就会知道这个地址是冲突的,并且不能使用。

私有地址

无状态地址自动配置会有一个安全隐患:即使一台设备从一个子网转移到另一个子网,它的接口 ID 始终保持不变。那么就可以通过接口 ID 来识别用户,推断出用户的所在位置,追踪用户的活动和位置记录,暴露个人隐私信息。

这个问题可以通过 IPv6 私有地址来解决。私有地址是随机生成的接口 ID。接口 ID 通常一天变化一次,也会在获取一个新的 IPv6 地址时改变。

但是服务器的地址不需要经常变化。跟服务器通信的节点,以及 DNS 服务器必须通过静态地址了解服务器的位置。因此,标准的无状态配置的 IPv6 地址保留“公共”地址,任何一个向服务器发送数据时,使用这个地址作为目的地址。但是服务器发送数据时,使用的却是私有地址。这就像公司的分机短号一样,你能看见是谁在打你电话,但是别人看不到你的号码。

NAT详解

IP 地址分为公网地址和私有地址。公网地址由 IANA 统一分配,用于连接互联网;私有地址可以自由分配,用于私有网络内部通信。

随着互联网用户的快速增长,2019 年 11 月 25 日全球的公网 IPv4 地址已耗尽。在 IPv4 地址耗尽前,使用 NAT(Network Address Translation)技术解决 IPv4 地址不够用的问题,并持续至今。

NAT 技术就是将私有地址转换成公网地址,使私有网络中的主机可以通过少量公网地址访问互联网。

但 NAT只是一种过渡技术,从根本上解决问题,是采用支持更大地址空间的下一代 IP 技术,即 IPv6 协议,它提供了几乎用不完的地址空间。

需要在专用网连接到互联网的路由器上安装 NAT 软件。装有 NAT 软件的路由器叫做 NAT 路由器,它至少有一个有效的外部全球 IP 地址。

所有使用本地地址的主机在和外界通信时,都要在 NAT 路由器上将其本地地址转换成全球 IP 地址,才能和互联网连接。

NAT 解决了 IPv4 地址不够用的问题,另外 NAT 屏蔽了私网用户真实地址,提高了私网用户的安全性。

NAT 技术

IP 地址中预留了 3 个私有地址网段,在私有网络内,可以任意使用。

其余的 IP 地址可以在互联网上使用,由 IANA 统一管理,称为公网地址。

典型的 NAT 组网模型,网络通常是被划分为私网和公网两部分,各自使用独立的地址空间。私网使用私有地址10.0.0.0/24,而公网使用公网地址。为了让主机 A 和 B 访问互联网上的服务器Server,需要在网络边界部署一台 NAT 设备用于执行地址转换。NAT 设备通常是路由器或防火墙。

基本 NAT

基本 NAT 是最简单的一种地址转换方式,它只对数据包的 IP 层参数进行转换,它可分为静态 NAT 和动态 NAT。

静态 NAT 是公网 IP 地址和私有 IP 地址有一对一的关系,一个公网 IP 地址对应一个私有 IP 地址,建立和维护一张静态地址映射表。

动态 NAT 是公网 IP 地址和私有 IP 地址有一对多的关系,同一个公网 IP 地址分配给不同的私网用户使用,使用时间必须错开。它包含一个公有 IP 地址池和一张动态地址映射表。

举个动态 NAT 栗子

私网主机 A(10.0.0.1)需要访问公网的服务器 Server(61.144.249.229),在路由器 RT 上配置 NAT,地址池为219.134.180.11 ~ 219.134.180.20,地址转换过程如下:

A 向 Server 发送报文,网关是10.0.0.254,源地址是10.0.0.1,目的地址是61.144.249.229

RT 收到 IP 报文后,查找路由表,将 IP 报文转发至出接口,由于出接口上配置了 NAT,因此 RT 需要将源地址10.0.0.1转换为公网地址。

RT 从地址池中查找第一个可用的公网地址219.134.180.11,用这个地址替换数据包的源地址,转换后的数据包源地址为219.134.180.11,目的地址不变。这叫 SNAT(Source Network Address Translation,源地址转换)。同时 RT 在自己的 NAT 表中添加一个表项,记录私有地址10.0.0.1到公网地址219.134.180.11的映射。RT 再将报文转发给目的地址61.144.249.229

Server 收到报文后做相应处理。

Server 发送回应报文,报文的源地址是61.144.249.229,目的地址是219.134.180.11

RT 收到报文,发现报文的目的地址219.134.180.11在 NAT 地址池内,于是检查 NAT 表,找到对应表项后,使用私有地址10.0.0.1替换公网地址219.134.180.11,转换后的报文源地址不变,目的地址为10.0.0.1。RT 在将报文转发给 A。这也叫 DNAT(Destination Network Address Translation,目的地址转换)。

A 收到报文,地址转换过程结束。

如果 B 也要访问 Server,则 RT 会从地址池中分配另一个可用公网地址219.134.180.12,并在 NAT 表中添加一个相应的表项,记录 B 的私有地址10.0.0.2到公网地址219.134.180.12的映射关系。

整个过程下来,NAT 悄悄的改了 IP 数据包的发送和接收端 IP 地址,但对真正的发送方和接收方来说,他们却对这件事情,一无所知。

这就是 基本 NAT 的工作原理。

当 NAT 路由器具有n个全球 IP 地址时,专用网内最多可以同时有n台主机接入到互联网。这样就可以使专用网内较多数量的主机,轮流使用 NAT 路由器有限数量的全球 IP 地址。

通过 NAT 路由器的通信必须由专用网内的主机发起。专用网内部的主机不能充当服务器用,因为互联网上的客户无法请求专用网内的服务器提供服务。

为了更加有效地利用 NAT 路由器上的全球 IP 地址,现在常用的 NAT 转换表把运输层的端口号也利用上。这样,就可以使多个拥有本地地址的主机,共用一个 NAT 路由器上的全球 IP 地址,因而可以同时和互联网上的不同主机进行通信。

使用端口号的 NAT 叫做网络地址与端口号转换 NAPT(Network Address and Port Translation),而不使用端口号的 NAT 就叫做基本的 NAT。

NAPT

在基础 NAT 中,私有地址和公网地址存在一对一地址转换的对应关系,即一个公网地址同时只能分配给一个私有地址。它只解决了公网和私网的通信问题,并没有解决公网地址不足的问题。

NAPT 对数据包的 IP 地址、协议类型、传输层端口号同时进行转换,可以明显提高公网 IP 地址的利用率。

举个栗子

私网主机 A( 10.0.0.1 )需要访问公网的服务器 Server 的 WWW 服务( 61.144.249.229 ),在路由器 RT 上配置 NAPT ,地址池为 219.134.180.11 ~ 219.134.180.20 ,地址转换过程如下:

A 向 Server 发送报文,网关是 RT( 10.0.0.254 ),源地址和端口是 10.0.0.1:1024 ,目的地址和端口是 61.144.249.229:80 。

RT 收到 IP 报文后,查找路由表,将 IP 报文转发至出接口,由于出接口上配置了 NAPT ,因此 RT 需要将源地址 10.0.0.1:1024 转换为公网地址和端口。

RT 从地址池中查找第一个可用的公网地址 219.134.180.11 ,用这个地址替换数据包的源地址,并查找这个公网地址的一个可用端口,例如 2001 ,用这个端口替换源端口。转换后的数据包源地址为 219.134.180.11:2001 ,目的地址和端口不变。同时 RT 在自己的 NAT 表中添加一个表项,记录私有地址 10.0.0.1:1024 到 公网地址 219.134.180.11:2001 的映射。RT 再将报文转发给目的地址 61.144.249.229 。

Server 收到报文后做相应处理。

Server 发送回应报文,报文的源地址是 61.144.249.229:80 ,目的地址是 219.134.180.11:2001 。

RT 收到报文,发现报文的目的地址在 NAT 地址池内,于是检查 NAT 表,找到对应表项后,使用私有地址和端口 10.0.0.1:1024 替换公网地址 219.134.180.11:2001,转换后的报文源地址和端口不变,目的地址和端口为 10.0.0.1:1024 。RT 再将报文转发给 A 。

A 收到报文,地址转换过程结束。

如果 B 也要访问 Server ,则 RT 会从地址池中分配同一个公网地址 219.134.180.11 ,但分配另一个端口 3001 ,并在 NAT 表中添加一个相应的表项,记录 B 的私有地址 10.0.0.2:1024 到公网地址 219.134.180.12:3001 的映射关系。

如果局域网内有多个设备,他们就会映射到不同的公网端口上,毕竟端口最大可达 65535,完全够用。这样大家都可以相安无事。

像这种同时转换 IP 和端口的技术,就是 NAPT(Network Address Port Transfer, 网络地址端口转换)。

那这么说只有用到端口的网络协议才能被 NAT 识别出来并转发?

但这怎么解释ping命令?ping基于 ICMP 协议,而 ICMP 协议报文里并不带端口信息。我依然可以正常的ping通公网机器并收到回包。

事实上针对 ICMP 协议,NAT 路由器做了特殊处理。ping报文头里有个Identifier的信息,它其实指的是放出ping命令的进程id

对 NAT 路由器来说,这个Identifier的作用就跟端口一样。

另外,当我们去抓包的时候,就会发现有两个Identifier,一个后面带个BE(Big Endian),另一个带个LE(Little Endian)。

其实他们都是同一个数值,只不过大小端不同,读出来的值不一样。就好像同样的数字 345,反着读就成了 543。这是为了兼容不同操作系统下大小端不同的情况。

Easy IP

在标准的 NAPT 配置中需要创建公网地址池,也就是必须先知道公网 IP 地址的范围。而在拨号接入的上网方式中,公网 IP 地址是由运营商动态分配的,无法事先确定,标准的 NAPT 无法做地址转换。要解决这个问题,就要使用 Easy IP。

Easy IP 又称为基于接口的地址转换。在地址转换时,Easy IP 的工作原理与 NAPT 相同,对数据包的 IP 地址、协议类型、传输层端口号同时进行转换。但 Easy IP 直接使用公网接口的 IP 地址作为转换后的源地址。Easy IP 适用于拨号接入互联网,动态获取公网 IP 地址的场合。

Easy IP 无需配置地址池,只需要配置一个 ACL(访问控制列表),用来指定需要进行 NAT 转换的私有 IP 地址范围。

NAT Server

从基本 NAT 和 NAPT 的工作原理可知,NAT 表项由私网主机主动向公网主机发起访问而生成,公网主机无法主动向私网主机发起连接。因此 NAT 隐藏了内部网络结构,具有屏蔽主机的作用。但是在实际应用中,内网网络可能需要对外提供服务,例如 Web 服务,常规的 NAT 就无法满足需求了。

为了满足公网用户访问私网内部服务器的需求,需要使用 NAT Server 功能,将私网地址和端口静态映射成公网地址和端口,供公网用户访问。

举个栗子

A 的私网地址为10.0.0.1,端口 8080 提供 Web 服务,在对公网提供 Web 服务时,要求端口号为 80 。在 NAT 设备上启动 NAT Server 功能,将私网 IP 地址和端口10.0.0.1:8080映射成公网 IP 地址和端口219.134.180.11:80,这样公网主机 C 就可以通过219.134.180.11:80访问 A 的 Web 服务。

NAT ALG

基本 NAT 和 NAPT 只能识别并修改 IP 报文中的 IP 地址和端口号信息,无法修改报文内携带的信息,因此对于一些 IP 报文内携带网络信息的协议,例如 FTP、DNS、SIP、H.323 等,是无法正确转换的。

ALG 能够识别应用层协议内的网络信息,在转换 IP 地址和端口号时,也会对应用层数据中的网络信息进行正确的转换。

举个栗子:ALG 处理 FTP 的 Active 模式

FTP 是一种基于 TCP 的协议,用于在客户端和服务器间传输文件。FTP 协议工作时建立 2 个通道:Control通道和Data通道。Control用于传输 FTP 控制信息,Data通道用于传输文件数据。

私网 A(10.0.0.1)访问公网 Server(61.144.249.229)的 FTP 服务,在 RT 上配置 NAPT,地址池为219.134.180.11 ~ 219.134.180.20,地址转换过程如下:

A 发送到 Server 的 FTP Control 通道建立请求,报文源地址和端口为10.0.0.1:1024,目的地址和端口为61.144.249.229:21,携带数据是 IP = 10.0.0.1 port=5001,即告诉 Server 自己使用 TCP 端口 5001 传输Data

RT 收到报文,建立10.0.0.1:1024219.134.180.11:2001的映射关系,转换源 IP 地址和 TCP 端口。根据目的端口 21,RT 识别出这是一个 FTP 报文,因此还要检查应用层数据,发现原始数据为IP = 10.0.0.1 port=5001,于是为Data通道10.0.0.1:5001建立第二个映射关系:10.0.0.1:5001219.134.180.11:2002,转换后的报文源地址和端口为219.134.180.11:2001,目的地址和端口不变,携带数据为IP = 219.134.180.11 port=2002

Server 收到报文,向 A 回应command okay报文,FTP Control 通道建立成功。同时 Server 根据应用层数据确定 A 的Data通道网络参数为 219.134.180.11:2002

A 需要从 FTP 服务器下载文件,于是发起文件请求报文。Server 收到请求后,发起Data通道建立请求,IP 报文的源地址和端口为61.144.249.229:20,目的地址和端口为219.134.180.11:2002,并携带 FTP 数据。

NAT 缺点

由于 NAT/NAPT 都依赖于自己的转换表,因此会有以下的问题:

  • 外部无法主动与 NAT 内部服务器建立连接,因为 NAPT 转换表没有转换记录。
  • 转换表的生成与转换操作都会产生性能开销。
  • 通信过程中,如果 NAT 路由器重启了,所有的 TCP 连接都将被重置。

解决办法

解决的方法主要有两种方法。

第一种就是改用 IPv6

IPv6 可用范围非常大,以至于每台设备都可以配置一个公有 IP 地址,就不搞那么多花里胡哨的地址转换了。

第二种 NAT 穿透技术

NAT 穿越技术拥有这样的功能,它能够让网络应用程序主动发现自己位于 NAT 设备之后,并且会主动获得 NAT 设备的公有 IP,并为自己建立端口映射条目,注意这些都是 NAT设备后的应用程序自动完成的。

也就是说,在 NAT 穿透技术中,NAT设备后的应用程序处于主动地位,它已经明确地知道 NAT 设备要修改它外发的数据包,于是它主动配合 NAT 设备的操作,主动地建立好映射,这样就不像以前由 NAT 设备来建立映射了。

说人话,就是客户端主动从 NAT 设备获取公有 IP 地址,然后自己建立端口映射条目,然后用这个条目对外通信,就不需要 NAT 设备来进行转换了。

内网穿透是什么

使用了 NAT 上网的话,前提得内网机器主动请求公网 IP,这样 NAT 才能将内网的 IP 端口转成外网 IP 端口。

反过来公网的机器想主动请求内网机器,就会被拦在 NAT 路由器上,此时由于 NAT 路由器并没有任何相关的 IP 端口的映射记录,因此也就不会转发数据给内网里的任何一台机器。

举个现实中的场景就是,你在你家里的电脑上启动了一个 HTTP 服务,地址是192.168.30.5:5000,此时你在公司办公室里想通过手机去访问一下,却发现访问不了。

那问题就来了,有没有办法让外网机器访问到内网的服务?

有。

说到底,因为 NAT 的存在,我们只能从内网主动发起连接,否则 NAT 设备不会记录相应的映射关系,没有映射关系也就不能转发数据。

所以我们就在公网上加一台服务器x,并暴露一个访问域名,再让内网的服务主动连接服务器x,这样 NAT 路由器上就有对应的映射关系。接着,所有人都去访问服务器x,服务器x将数据转发给内网机器,再原路返回响应,这样数据就都通了。这就是所谓的内网穿透。

像上面提到的服务器x,你也不需要自己去搭,已经有很多现成的方案,比如花某壳。

为什么我在公司里访问不了家里的电脑?

那是因为家里的电脑在局域网内,局域网和广域网之间有个 NAT 路由器。由于 NAT 路由器的存在,外网服务无法主动连通局域网内的电脑。

两个内网的聊天软件如何建立通讯

我家机子是在我们小区的局域网里,班花家的机子也是在她们小区的局域网里。都在局域网里,且 NAT 只能从内网连到外网,那我电脑上登录的QQ是怎么和班花电脑里的QQ连上的呢?

上面这个问法其实是存在个误解,误以为两个qq客户端应用是直接建立连接的。

然而实际上并不是,两个qq客户端之间还隔了一个服务器。

也就是说,两个在内网的客户端登录qq时都会主动向公网的聊天服务器建立连接,这时两方的 NAT 路由器中都会记录有相应的映射关系。当在其中一个qq上发送消息时,数据会先到服务器,再通过服务器转发到另外一个客户端上。反过来也一样,通过这个方式让两台内网的机子进行数据传输。

两个内网的应用如何直接建立连接

上面的情况,是两个客户端通过第三方服务器进行通讯,但有些场景就是要抛开第三端,直接进行两端通信,比如P2P下载,这种该怎么办呢?

这种情况下,其实也还是离不开第三方服务器的帮助。

假设还是 A 和 B 两个局域网内的机子,A 内网对应的 NAT 设备叫NAT_A,B 内网里的 NAT 设备叫NAT_B,和一个第三方服务器server

流程如下。

step1和2: A 主动去连server,此时A对应的NAT_A就会留下 A 的内网地址和外网地址的映射关系,server也拿到了 A 对应的外网 IP 地址和端口。

step3和4: B 的操作和 A 一样,主动连第三方serverNAT_B内留下 B 的内网地址和外网地址的映射关系,然后server也拿到了 B 对应的外网 IP 地址和端口。

step5和step6以及step7: 重点来了。此时server发消息给 A,让 A 主动发 UDP 消息到 B 的外网 IP 地址和端口。此时NAT_B收到这个 A 的 UDP 数据包时,这时候根据NAT_B的设置不同,导致这时候有可能NAT_B能直接转发数据到 B,那此时 A 和 B 就通了。但也有可能不通,直接丢包,不过丢包没关系,这个操作的目的是给NAT_A上留下有关 B 的映射关系。

step8和step9以及step10: 跟step5一样熟悉的配方,此时server再发消息给 B,让 B 主动发 UDP 消息到 A 的外网 IP 地址和端口。NAT_B上也留下了关于 A 到映射关系,这时候由于之前NAT_A上有过关于 B 的映射关系,此时NAT_A就能正常接受 B 的数据包,并将其转发给 A。到这里 A 和 B 就能正常进行数据通信了。这就是所谓的 NAT 打洞。

step11: 注意,之前我们都是用的 UDP 数据包,目的只是为了在两个局域网的 NAT 上打个洞出来,实际上大部分应用用的都是 TCP 连接,所以,这时候我们还需要在 A 主动向 B 发起 TCP 连接。到此,我们就完成了两端之间的通信。

这里估计大家会有疑惑。

端口已经被 UDP 用过了,TCP 再用,那岂不是端口重复占用(address already in use)?

其实并不会,端口重复占用的报错常见于两个 TCP 连接在不使用SO_REUSEADDR的情况下,重复使用了某个 IP 端口。而 UDP 和 TCP 之间却不会报这个错。之所以会有这个错,主要是因为在一个linux内核中,内核收到网络数据时,会通过五元组(传输协议,源 IP,目的 IP,源端口,目的端口)去唯一确定数据接受者。当五元组都一模一样的时候,内核就不知道该把数据发给谁。而 UDP 和 TCP 之间”传输协议”不同,因此五元组也不同,所以也就不会有上面的问题。

NAPT 还分为好多种类型,上面的 NAT 打洞方案,都能成功吗?

关于 NAPT,确实还细分为好几种类型。我们现在常见的都是锥形 NAT。上面的打洞方案适用于大部分场景,这其中包括限制最多的端口受限锥形 NAT。

总结

IPV4 地址有限,但通过 NAT 路由器,可以使得整个内网 N 多台机器,对外只使用一个公网 IP,大大节省了 IP 资源。

内网机子主动连接公网 IP,中间的 NAT 会将内网机子的内网 IP 转换为公网 IP,从而实现内网和外网的数据交互。

普通的 NAT 技术,只会修改网络包中的发送端和接收端 IP 地址,当内网设备较多时,将有可能导致冲突。因此一般都会使用 NAPT 技术,同时修改发送端和接收端的 IP 地址和端口。

由于 NAT 的存在,公网 IP 是无法访问内网服务的,但通过内网穿透技术,就可以让公网 IP 访问内网服务。一波操作下来,就可以在公司的网络里访问家里的电脑。

NAT 实战

基本 NAT 实验

实验拓扑图

实验要求

  • ENSP 模拟器
  • PC 通过公网地址访问互联网

实验步骤

根据接口 IP 地址表,配置各个设备的接口地址。

在 RT 上配置 NAT 配置。

配置基本 NAT 只需要一条命令:把私有 IP 地址转换成公网 IP 地址,在接口视图下配置nat static global global-address inside host-address命令。默认路由是网关路由器上的常见配置。使用display nat static命令查看 RT 上的静态 NAT 配置。

在 PC 上验证联网功能。

抓包查看 NAT 转换效果。分别抓包 RT 的内网口G0/0/0和外网口G0/0/1的报文,看出发送的Echo Request报文和接收的Echo Reply报文都有进行 NAT 转换。

NAPT 实验

实验拓扑图

实验要求

  • RT 使用 NAPT 功能
  • ISP 分配 4 个可用的公网地址:202.0.0.3 ~ 202.0.0.6
  • VLAN 10 的用户使用两个公网地址
  • VLAN 20 的用户使用另外两个公网地址

实验步骤

根据接口 IP 地址表,配置各个设备的接口地址。配置命令可参考上一个实验步骤 1。

在 RT 上配置 NAPT 配置。

在 NAPT 的配置中,使用基本 ACL 来指定私有 IP 地址范围。ACL 2010 指定 VLAN 10 的 IP 地址空间,ACL 2020 指定 VLAN 20 的 IP 地址空间。使用nat address-group group-index start-address end-address命令指定公网 IP 地址范围,分别指定了两个 NAT 地址组,编号分别选择了 1 和 2。在外网接口上,使用nat outbound acl-number address-group group-index,绑定 NAT 转换关系。

使用display nat address-group命令查看 RT 上的 NAT 地址组配置。命令display nat outbound查看出方向 NAT 的转换关系。

分别在 PC 10 和 PC 20 上验证上网功能。

抓包查看 NAT 转换效果。分别抓包 RT 的内网口G0/0/1和外网口G0/0/0的报文,查看 VLAN 10 的用户出发送的Echo Request报文和接收的Echo Reply报文都有进行 NAT 转换。

其它常用 NAT 命令

NAT Server 是在接口视图下配置,命令格式为:nat server protocol { tcp | udp } global global-address global-port inside host-address host-port

检查 NAT Server 配置信息命令:display nat server

检查 NAT 会话命令:display nat session all

启动 NAT ALG 功能命令:nat alg all enable

查看 NAT ALG 功能命令:display nat alg

RIP详解

RIP(Routing Information Protocol,路由信息协议)是典型的距离矢量路由协议,常用在小型网络中,是最先广泛使用的 IGP 协议。

运行 RIP

每台运行 RIP 的路由器,都有一个 RIP 数据库,里面存着路由器所有的 RIP 路由,包括路由器本身的直连路由,以及从其它路由器收到的路由。RIP 数据库的路由条目包含:目的网络地址/网络掩码、度量值、下一跳地址、老化计时器以及路由状态标识等信息。RIP 数据库中的有效路由条目才会添加到路由器的路由表中。

每台运行 RIP 的路由器都会定期的通告自己的路由表,当路由器收到 RIP 路由更新时,如果这些路由是自己路由表里没有的,并且是有效的,那么就把它添加到路由表中,同时设置路由的度量值和下一跳地址。

下面举个栗子,看下运行了 RIP 的路由器是如何完成路由信息的学习和收敛的。

1、路由器启动

R1、R2 和 R3 三台路由器直连,三台路由器都已开启 RIP。在启动路由器后,所有路由器自动发现自己的直连路由,并将直连路由添加到路由表中。比如:R1 的路由表中添加了192.168.12.0/241.0.0.0/8两条直连路由。直连路由的 RIP 度量值为 0 跳,0 跳表示到达这个网段不需要经过路由器。

2、第一次交换路由信息

运行了 RIP 的路由器会将自己的路由通过 RIP 报文周期性的从接口发送出去。第一次交换路由信息,R1、R2 和 R3 都是通告自己的直连路由。R2 会将自己的路由表从G0/0G0/1接口发送出去。以192.168.23.0/24为例,R2 从G0/0口发送给 R1 时,会将路由的度量值从 0 跳改为 1 跳,RIP 路由器将路由发送出去时会把跳数加 1,意思是要到达192.168.23.0/24需要经过一个 RIP 路由器。R1 收到 R2 发出的路由更新后,发现自己的路由表没有192.168.23.0/24这条路由,于是把这条路由添加到路由表中,路由的度量值为 1 跳,出接口设置为G0/0

R3 也会收到 R2 的路由更新,R2 也会收到 R1 和 R3 发送的路由更新。经过第一轮的路由通告和学习,R1 学习到192.168.23.0/24的路由,R2 学习到1.0.0.0/83.0.0.0/8两条路由,R3 学习到192.168.12.0/24的路由。

3、路由收敛

来到下一个更新周期时,所有路由器又会把自己的路由发送出去。R1 收到 R2 通告的路由,发现3.0.0.0/8不在路由表中,R1 就把这条路由添加到路由表,度量值为 2 跳,表示 R1 到达3.0.0.0/8需要经过两个路由器。另一边的 R3 也从 R2 学到了1.0.0.0/8的路由。这样三台路由器就有了全网各个网段的路由,路由表也稳定下来,这个状态说明网络中的路由已经完成了收敛。网络收敛后,路由器还是会周期性的通告路由,确保路由的有效性。

RIP 相关概念

如果从单台 RIP 路由器上看,它只是监听直连 RIP 路由器的路由更新通告,学习路由,并加载到路由表中。同时,它也将自己路由表中的 RIP 路由通告出去,让其它直连的路由器能够学习到。实际上,路由器是不知道整个网络的拓扑结构的。

度量值

度量值是到达目的网络的代价。每一种路由协议都定义了路由的度量值,但是对度量值的定义不尽相同。度量值的大小会影响路由器到达目的网段的路由选择。

RIP 以跳数作为路由的度量值,跳数就是到达目的网络需要经过的路由器个数,跳数越少,路由越优。

路由器运行 RIP 后,认为直连路由的跳数为 0。R1 的直连网段1.0.0.0/8的度量值是 0。R1 从G0/0接口通告路由时,会把1.0.0.0/8的度量值 +1 后通告给 R2。R2 收到后,将1.0.0.0/8路由学习过来,添加到路由表中,并将度量值设置为 1。接着 R2 将1.0.0.0/8路由从G0/1接口通告出去,R2 将路由的度量值 +1 后通告给 R3。R3 也学习到1.0.0.0/8路由,度量值为 2。

RIP 使用跳数作为度量值,可以让路由器知道自己距离目的网络的跳数。在路由选择过程中,比较度量值选择一条最优路径,也就是跳数最小的路径添加到路由表中。

通过跳数来计算网络的路径非常简单和直观,但是也有个问题。如果网络链路带宽不一致,RIP 的路径选择可能不合理,因为 RIP 并不关注链路带宽,只关注经过的路由器个数。这种场景下,可以对 RIP 路由的度量值进行适当调整,改变数据流量的转发路径。

报文类型及格式

RIP 协议报文使用 UDP 封装,使用的端口号是 520。RIP 有两种报文,分别是请求报文和响应报文。RIPv1 和 RIPv2 的功能不同,所以报文中的字段定义有一些差别。Request报文是向邻居请求全部或部分 RIP 路由信息,Response报文是发送 RIP 路由更新,Response报文中携带路由及路由的度量值等信息。

当路由器的接口激活 RIP 后,这个接口会立即发送一个Request报文和Response报文,并开始侦听 RIP 协议报文。然后开始周期性的发送Response报文。RIPv1 使用广播地址255.255.255.255作为协议报文的目的 IP 地址,而 RIPv2 使用组播地址224.0.0.9作为协议报文的目的 IP 地址。当 RIP 路由器收到Request报文后,会使用Response报文进行回应,在报文中携带对方请求的路由信息。当 RIP 路由器收到Response报文后,会解析报文中的路由信息,如果路由信息是自己未发现的,并且路由的度量值有效,那么路由器将学习这条路由并添加到路由表中,同时为这条路由关联度量值、出接口和下一跳信息。

RIPv1 报文结构

  • 命令(Command):表示 RIP 报文类型。值为 1 时表示Request报文,向直连路由器请求全部或部分路由信息。值为 2 时表示Response报文,用于发送路由更新。可以是Request报文的回应,也可以是路由器主动发送的。一个Response报文最多携带 25 个路由条目,当路由数量超过 25 时,会使用多个Response报文发送。
  • 版本(Version):RIPv1 时,值为 1。
  • 地址族标识符(Address Family Identifier,AFI):值为 2 时表示 IP 协议。如果是请求整个路由表的Request报文,则值为 0 ,同时Request报文有且只有一个路由条目,路由的目的网段为0.0.0.0,度量值为 16。
  • IP 地址(IP Address):路由的目的网络地址。
  • 度量值(Metric):路由的度量值。

RIPv2 报文结构

  • 命令(Command):和 RIPv1 相同。
  • 版本(Version):RIPv2 时,值为 2。
  • 地址族标识符(Address Family Identifier,AFI):和 RIPv1 相同。
  • 路由标记(Route Tag):用于标记路由信息,默认值为 0 。当有一条外部路由引入并形成一条 RIP 路由时,这条路由设置路由标记。
  • IP 地址(IP Address):路由的目的网络地址。
  • 网络掩码(Netmask):RIPv2 定义的字段,用于表示路由的目的网络掩码,支持 VLSM(可变长子网掩码)。RIPv1 没有定义这个字段,不支持 VLSM。
  • 下一跳(Next Hop):RIPv2 定义的字段,多路访问网络中,可自定义指定最优路径。通常路由器发送的路由更新中,下一跳字段为0.0.0.0,收到的路由器将路由条目添加到路由表中,将路由的发送方设置为目的网段的下一跳。在特殊场景下,字段值会设置为非0.0.0.0
  • 度量值(Metric):路由的度量值。

计时器

RIP 有三个重要的计时器。

  • 更新计时器(Update Timer):RIP 路由器周期性发送response报文的时间间隔。默认时间是 30 秒。
  • 老化计时器(Age Timer):每一条 RIP 路由器都有老化计时器。当 RIP 路由添加到路由表时,马上为这条路由启动老化计时器,默认时间是 180 秒,启动后即开始计时。路由器再次收到这条路由的更新,老化计时器会重置并重新开始计时。当一条路由的老化计时器超时,路由表会删除这条路由,但是还会保存在 RIP 数据库中,以便路由随时能够恢复。在老化计时器超时的同时,这条路由的垃圾回收计时器也会立即启动。有趣的地方是,老化计时器超时失效的 RIP 路由,依然会在路由器发送的Response报文中,不过路由的度量值设置为 16 跳,即不可达。
  • 垃圾回收计时器(Garbage-Collect Timer):垃圾回收计时器默认值是 120 秒。当 RIP 路由的老化计时器超时,路由会不可达并从路由表中删除,但是会保存在 RIP 数据库中,同时立即为这条路由启动垃圾回收计时器。在垃圾回收计时器的这段时间,路由器会这条路由的度量值设置为 16 跳进行通告,告诉其他路由器这个网络不可达。如果垃圾回收计时器也超时,那么路由会被彻底删除。

路由表更新

路由器收到Response报文后,查看报文中的路由,并更新路由表。路由表的更新原则是:

  • 对于路由表中已有的路由条目,路由条目的下一跳与发送Response报文的路由器相同时,不论路由的度量值是增大或是减少,都更新这条路由。度量值相同时,只重置路由的老化计时器。
  • 对于路由表中已有的路由条目,路由条目的下一跳与发送Response报文的路由器不同时,只有在路由的度量值减少时,才更新这条路由。
  • 对于路由表中不存在的路由条目,且度量值小于 16 时,才添加进路由表。

RIP 防环机制

如果网络中的路由信息不正确,会导致数据包在设备之间来回转发,不能正确发往目的地,还会消耗带宽和设备性能,影响正常的业务流量,这种问题就是路由环路问题。路由环路对于网络而言,是具有严重危害的,我们应该重视并尽量规避这种问题,RIP 便有考虑路由环路的规避机制。

环路的产生

R1 和 R2 都运行了 RIP,当网络收敛后,R2 通过 RIP 学习到了1.0.0.0/8路由。现在 R1 的G0/1接口发生故障,R1 感知到这个拓扑变化,并立刻删除1.0.0.0/8路由。然而 R2 并不知道这个拓扑变化,R1 准备在下一个更新周期进行通告。这时就会出现一种可能,R1 在更新之前,R2 的更新周期到了,从G0/0接口发生Response报文,报文包含1.0.0.0/8路由,且跳数为 2。R1 收到这个Response报文后,发现1.0.0.0/8可以通过 R2 到达,且跳数为 2,于是 R1 将1.0.0.0/8路由添加到路由表中。

这就出现了环路。如果 R2 收到一个发往1.0.0.0/8的数据包,R2 查询路由表发现有目的地址的路由,下一跳是 R1,于是 R2 将数据包转发给 R1。而 R1 查询路由表,发现到达1.0.0.0/8下一跳是 R2,于是数据包又被转发给 R2 ,如此反复,数据包会在 R1 和 R2 之间不停转发,直到报文的 TTL 值变为 0。

另外,RIP 每隔 30 秒泛洪一次路由表,每个更新周期都会泛洪Response报文里的1.0.0.0/8路由。R1 更新周期到来时,会把1.0.0.0/8路由通告给 R2,R2 收到报文后,刷新路由表,把这条路由的跳数更新为 3 跳。当 R2 更新周期到来时,将1.0.0.0/8发送给 R1,R1 收到后,刷新路由表,跳数更新为 4 跳,如此反复,跳数也会持续加大到无穷大。

这些都是因为 RIP 路由器并不了解整个网络的拓扑结构,使得网络中非常容易出现环路,路由环路对网络而言也是危害巨大,因此从网络设计和协议设计都要考虑到环路的可能性,并加以规避。

最大跳数

为了规避 RIP 路由在网络中无止境的泛洪,RIP 定义了路由的最大跳数是 15 跳,当一条路由的度量值达到 16 跳时,这条路由会被认为是不可用的,路由指向的网段是不可达的。

定义最大跳数,虽然解决了路由无限泛洪的问题,但是也限制了 RIP 能够支持的网络规模,而且没有从根本上解决路由环路的问题。

水平分割

水平分割(Split Horizon)的原理是,RIP 路由器从接口收到的路由不会再从这个接口通告出去,具体地说,不能把从邻居学习到的路由发送给那个邻居。这个机制消除了 RIP 路由的环路隐患。

R1 和 R2 运行了 RIP,现在 R1 将本地直连路由1.0.0.0/8通过Response报文通告出去,路由度量值为 1。R2 从G0/0接口收到后,学习到1.0.0.0/8路由,并添加到路由表中。当 R2 的更新周期到来时,如果 R2 的G0/0接口激活了水平分割,R2 不会把从G0/0接口收到的 RIP 路由再从这个接口通告出去,也就是说,R2 不会把1.0.0.0/8路由从G0/0接口通告出去。这样路由环路的问题就可以很好的规避。

毒性逆转

毒性逆转(Poison Reverse)是另一种防止路由环路的机制,原理是 RIP 从接口学习到路由后,当它从这个接口发送Response报文时会携带这些路由,但是路由度量值设置为 16 跳,16 跳意味着路由不可达。使用这种方式,可以清楚对方路由表中的无用路由。毒性逆转也可以防止产生路由环路。

R1 和 R2 两台路由器运行了 RIP,开始交互 RIP 路由。R1 将1.0.0.0/8路由通告给 R2。如果 R2 激活了毒性逆转,那么它从G0/0接口发送Response报文时,报文包含1.0.0.0/8路由,但是路由的度量值为 16 跳。

由于 R2 到达1.0.0.0/8的路由是从 R1 学习到的,意味着这个网段是在 R1 侧,可能是 R1 的直连网络,也可能是 R1 通过其它路由器到达这个网段。换句话说,R1 不会从 R2 到达1.0.0.0/8,也就不会出现环路。所以毒性逆转的思路是 R2 认为这条路由是 R1 给的,那么 R1 不可能从我这里到达这个网段,所以我就告诉 R1 ,这个网络从我这里走是不可达的。这条不可达路由可以彻底杜绝 R1 从 R2 到达1.0.0.0/8,避免环路出现的可能性。

这样看,会发现毒性逆转和水平分割实现了同一个功能,但是又互相矛盾。对水平分割,简单理解就是:到达某个目的网段的路由既然是你告诉我的,那么我就不应该再说回给你听。这是一种相对消极的举动。而毒性逆转则显得更主动积极:到达某个目的网段的路由是你告诉我的,那么我主动告诉你这个网段从我这走不通,杜绝你从我这走的可能。从这个层面上看,毒性逆转比水平分割更靠谱。如果路由器的接口同时激活水平分割和毒性逆转,只有毒性逆转生效。

触发更新

前面讲过,路由器激活 RIP 后,接口会周期性的发送Response报文,默认情况下,RIP 会以 30 秒为周期进行报文发送。这在网络稳定时没有问题,但是网络拓扑出现变化时,那就要等到下一个更新周期才能发送路由更新,这就不合理,而且容易引发路由环路。

触发更新机制是当路由器感知到拓扑变化或路由度量值变化时,它不是等下一个更新周期,而是立即发送Response报文。举个栗子,R1 、R2 和 R3 三台路由器运行了 RIP,R1 通告的1.0.0.0/8路由的度量值发生变化,从 1 跳变为 2 跳,R1 向 R2 发送Response报文进行通告。因为 R2 的 1.0.0.0/8 路由就是从 R1 学过来的,所以即使新的度量值 2 跳更远,R2 也会立即刷新自己的路由表,并且不等下一个更新周期,立即触发一个Response报文给 R3。R3 收到报文后,立即刷新自己的路由表。

毒性路由

度量值为 16 跳的路由是不可达的,当一个网络变为不可达时,路由器立即发送一个 16 跳的路由更新,通知网络中的路由器目的网络已经不可达,这种路由叫做毒性路由。

R1 的直连网段1.0.0.0/8变为不可达后,R1 立即发送Response报文通告这个更新,报文里的1.0.0.0/8路由度量值设置为 16。R2 收到这个Response报文后,发现1.0.0.0/8不可达了,于是从路由表中移除这条路由。其中,R2 虽然将路由从路由表中删除,但是依然保存在 RIP 数据库中,同时启动垃圾回收计时器。

RIPv2

RIPv1 是有类路由协议,不支持 VLSM,因此只能在特定的网络环境中使用。其中一个原因是,RIPv1 的Response报文中只有 IP 地址(目的网络地址)而没有目的网络掩码,使得 RIPv1 在使用 VLSM 的网络中会出现问题。

R1 连接着172.16.1.0/24,R3 连接着172.168.3.0/24,这时172.16.0.0/16这个 B 类地址被192.168.12.0/24192.168.23.0/24两个 C 类地址隔开,这就是不连续的主类网络。

R1 、R2 和 R3 三台路由器都运行了 RIPv1,泛洪的Response报文中是不携带目的网络掩码的,会自动汇总成主类路由进行通告。R1 和 R3 都会向 R2 发送Response报文,报文都包含172.16.0.0路由,R2 收到两份Response报文,将两条路由都添加到路由表中,这样 R2 的路由表172.16.0.0/16路由会有两个等价的下一跳。R2 转发172.16.3.0/24的数据包,可能发往 R1 导致故障;R2 转发172.16.1.0/24的数据包,可能发往 R3 导致故障;这就是 RIPv1 在不连续主类网络时存在的问题。推荐的解决办法就是使用 RIPv2 而不是 RIPv1。

RIPv2 的改进点包括使用组播发送 RIP 报文;支持无类路由选择;在Response报文中携带目的网络掩码;支持报文认证;增加下一跳特性;增加路由标记功能;支持手动路由汇总等。

报文发送方式

RIPv1 使用广播发送协议报文,报文目的 IP 地址是255.255.255.255,这是一个广播 IP 地址,同一个广播域的设备都能收到报文,即使有些设备不需要也要耗费资源去处理。比如:广播域中的主机、服务器等未运行 RIP 的设备,收到一个 RIPv1 报文后,要层层解封装,直到看到报文的目的 UDP 端口,发现未侦听 UDP 520 端口才会将报文丢弃。

RIPv2 采用组播地址224.0.0.9作为报文的目的 IP 地址,所有 RIPv2 设备都会侦听这个组播地址,可以减少对广播域其它设备的影响。

报文认证

R1 和 R2 交互着 RIP 报文,现在 R3 连接到交换机上,开始在广播域泛洪Response报文,这些伪造的Response报文携带大量垃圾路由,这会造成 R1 和 R2 的路由出现混乱,或者路由表被大量垃圾路由填充,设备资源也会被大量消耗。

RIPv2 的解决方法是 RIP 报文认证。通过在 R1 和 R2 的接口上激活 RIP 认证并在两端配置相同的认证口令,可使 RIP 报文的交互更加安全。只有 RIP 报文的相关认证字段匹配认证口令,报文才是有效的,否则是非法报文并被丢弃。

RIP 认证是基于报文的,路由器接口上配置 RIP 报文认证后,这个接口发送的 RIP 报文会携带认证信息。认证信息会占用第一个路由项,此时一个Response报文可携带的最大路由条目数量从 25 条变成 24 条。RIP 认证方式有:

  • 简单认证
  • MD5 认证

下一跳字段

R1 、R2 和 R3 连在同一台交换机上,R1 和 R3 运行 RIPv2 ,但 R2 不支持 RIP。R2 直连着2.0.0.0/8,为了让 R1 能够访问这个网段,在 R1 上部署静态路由:ip route-static 2.0.0.0 8 192.168.123.2。现在让 R3 也能够访问2.0.0.0/8,而且希望通过 RIP 学习到这条路由,R1 将静态路由引入 RIP。这样 R3 把路由添加到路由表,认为 R1 是下一跳。这样的话,从 R3 到2.0.0.0/8的数据包会先转发给 R1,再由 R1 转发给 R2,这就是次优路径。对于距离矢量路由协议而言,路由的通告者就是路由的下一跳。

在同一个网络拓扑结构中,如果存在两种不同的路由协议,会造成网络中路由信息的隔离。在路由协议的边界设备上,将路由信息引入到另一种路由协议中,这就称为路由引入或路由重分发。

RIPv2 增加下一跳字段解决这个问题,当 R1 将2.0.0.0/8路由通过 RIP 通告给 R3 时,Response报文会携带下一跳字段,值为 R1 到达目的网段的直连下一跳地址,也就是直连网段中的192.168.123.2(R2 的接口地址)。R3 收到Response报文后,将路由2.0.0.0/8添加到路由表,下一跳设置为192.168.123.2(这个地址直连可达)。R3 去往2.0.0.0/8的数据包会直接转发给 R2,而不会经过 R1 去转发。

路由标记

RIPv2 增加了路由标记(Route Tag)字段,从外部引入 RIP 的路由能够携带特定的标记信息。由连续的 RIP 路由器构成的网络称为 RIP 域,RIP 域内的路由通告network命令发布路由,整个域内的 RIP 路由器都能学习到,这些路由的路由标记字段值为 0。当一条外部路由,比如静态路由、OSFP 或 BGP 路由等,重分发到 RIP 时,RIP 为这条路由设置路由标记。

路由汇总

路由汇总是同一个网段内的不同子网路由在向外通告时汇总成一条路由的行为。路由汇总主要用于减小网络设备的路由表规模,进而减小网络中路由更新的流量及设备资源消耗。在一个大型的网络中路由汇总是必须考虑的一种网络优化手段。

R1 连着172.16.1.0/24、172.16.2.0/24172.16.3.0/24等大量网段,如果 R1 将路由全部通告给 R2,那么 R2 的路由表会变得臃肿,而且更新路由又要占用不少带宽资源。仔细一看,发现这些网络是可以通过路由汇总进行优化的。

如果我们在 R1 上部署路由汇总,那么 R1 不再通告172.16.0.0/16的子网路由给 R2 ,而是通告汇总路由172.16.0.0/16,那么 R2 的路由表将从 256 条精简到 1 条,R2 转发这些子网的报文是,可以使用汇总路由来转发。同时要记住,路由汇总的前提是 IP 地址规划合理,子网可以进行路由汇总。

RIP 支持路由自动汇总,路由自动汇总是 RIP 路由器把一个主类网络的子网路由通告给另一个主类网络时,自动将子网路由汇总成主类网络路由,只把主类网络路由通告给直连 RIP 路由器的过程。因为 RIP 路由自动汇总只能把明细路由汇总成主类网络路由,这就会存在准确度不高的问题。

  • RIPv1 的路由自动汇总功能默认是开启状态,而且不能关闭;
  • RIPv2 的路由自动汇总功能默认是开启状态,但是可以通过命令关闭。

R1 启动路由自动汇总功能后,R1 向 R2 通告172.16.0.0/16的子网路由时,R1 会执行路由自动汇总,将明显路由汇总成主类网络路由172.16.0.0/16通告给 R2。在路由汇总的执行过程中,只要存在一条明细路由,则这条明细路由对应的主类网络汇总路由就会被通告,而如果所有的明细路由都失效,那么 RIP 不再通告对应的汇总路由。

路由自动汇总功能使用方便,但是在某些场景中使用时可能存在问题。R1、R2 和 R3 都运行 RIPv2,R1 左侧连接着172.16.1.0/24、172.16.2.0/24 ······ 172.16.31.0/24这些子网,会将子网路由汇总成主类路由172.16.0.0/16进行通告,遗憾的是 R3 会进行同样的操作,也会向 R2 通告172.16.0.0/16汇总路由,这样 R2 会分别收到 R1 和 R3 的172.16.0.0/16路由的更新,R2 会根据度量值进行路由选择,如果度量值相等,那么 R2 会执行路由等价负载分担,也就是把这两条路由都加载到路由表中,这就会出现问题,转发给 R1 的流量可能会转发给 R3。

造成问题的原因是汇总路由的准确度不高,也就是说汇总路由的掩码不够精准。如果关闭自动汇总,路由表又会变得臃肿。那该如何解决呢?答案是使用 RIP 手动路由汇总,也就是在 R1 和 R3 上先关闭路由自动汇总,然后使用手动汇总来指定 RIP 通告的精确汇总路由。手动汇总可以自定义汇总路由的目的网络地址及网络掩码,而不受地址类别的限制。R1 关闭 RIP 路由自动汇总后使用 RIP 路由手动汇总,向 R2 通告汇总路由172.16.0.0/19,而 R3 也关闭 RIP 路由自动汇总并向 R2 通告另一条汇总路由172.16.32.0/19。这两条汇总路由都精确包括了相应的明细路由,并不会在 R2 上形成冲突,解决路由自动汇总产生的问题。

路由器

随着接入网络的终端越来越多,网络规模越来越大,但是二层交换机的容量和性能有限,无法接入日益增多的终端。于是就有了三层网络设备路由器,连接不同网段的二层交换机,进而把全世界的网络都连接起来。

路由器

路由器是负责网络层工作的硬件设备,通过不同端口,连接不同的网段,识别目的地址,根据路由表进行数据包转发。

路由器的结构

路由器是一种具有多个输入端口和多个输出端口的专用计算机,其任务是转发分组。也就是说,将路由器某个输入端口收到的分组,按照分组要去的目的地(即目的网络),把该分组从路由器的某个合适的输出端口转发给下一跳路由器。

下一跳路由器也按照这种方法处理分组,直到该分组到达终点为止。

典型的路由器的结构

整个的路由器结构可划分为两大部分:路由选择部分、分组转发部分。

路由选择部分也叫做控制部分,其核心构件是路由选择处理机。

路由选择处理机的任务是根据所选定的路由选择协议构造出路由表,同时经常或定期地和相邻路由器交换路由信息而不断地更新和维护路由表。

分组转发部分由三部分组成:

  • 交换结构:根据转发表对分组进行处理。
  • 一组输入端口
  • 一组输出端口

“路由选择”则是按照分布式算法,根据从各相邻路由器得到的关于网络拓扑的变化情况,动态地改变所选择的路由。

输入端口对线路上收到的分组的处理

路由器的输入端口里面装有物理层、数据链路层和网络层的处理模块。

数据链路层剥去帧首部和尾部后,将分组送到网络层的队列中排队等待处理。这会产生一定的时延。

输出端口里面装有物理层、数据链路层和网络层的处理模块。

输出端口从交换结构接收分组,然后把它们发送到路由器外面的线路上。

在网络层的处理模块中设有一个缓冲区(队列)。当交换结构传送过来的分组的速率超过输出链路的发送速率时,来不及发送的分组就必须暂时存放在这个队列中。

数据链路层处理模块将分组加上链路层的首部和尾部,交给物理层后发送到外部线路。

分组丢弃

若路由器处理分组的速率赶不上分组进入队列的速率,则队列的存储空间最终必定减少到零,这就使后面再进入队列的分组由于没有存储空间而只能被丢弃。

路由器中的输入或输出队列产生溢出是造成分组丢失的重要原因。

交换结构

交换结构是路由器的关键构件。正是这个交换结构把分组从一个输入端口转移到某个合适的输出端口。

实现交换有多种方法。常用交换方法有三种:通过存储器、通过总线、通过纵横交换结构。

通过存储器

(1) 当路由器的某个输入端口收到一个分组时,就用中断方式通知路由选择处理机。然后分组就从输入端口复制到存储器中。
(2) 路由器处理机从分组首部提取目的地址,查找路由表,再将分组复制到合适的输出端口的缓存中。
(3) 若存储器的带宽(读或写)为每秒 M 个分组,那么路由器的交换速率(即分组从输入端口传送到输出端口的速率)一定小于 M/2。

通过总线

(1) 数据报从输入端口通过共享的总线直接传送到合适的输出端口,而不需要路由选择处理机的干预。
(2) 因为每一个要转发的分组都要通过这一条总线,因此路由器的转发带宽就受总线速率的限制。
(3) 现代的技术已经可以将总线的带宽提高到每秒吉比特的速率,因此许多的路由器产品都采用这种通过总线的交换方式。

通过纵横交换结构

(1) 这种交换结构常称为互连网络。
(2) 它有 2N 条总线,可以使 N 个输入端口和 N 个输出端口相连接。
(3) 当输入端口收到一个分组时,就将它发送到与该输入端口相连的水平总线上。
(4) 若通向所要转发的输出端口的垂直总线是空闲的,则在这个结点将垂直总线与水平总线接通,然后将该分组转发到这个输出端口。
(5) 但若该垂直总线已被占用(有另一个分组正在转发到同一个输出端口),则后到达的分组就被阻塞,必须在输入端口排队。

路由选择

路由器为数据包选择路径的过程叫做路由选择。

路由器从接口收到数据包后,根据目的地址的信息进行路由选择,按照选择结果将数据包从对应接口转发出去。

转发的路线叫做路径。

路由器在路由选择时,参考的信息叫做路由表。路由器通过这些信息判断数据包转发到哪个网络。

路由表由多个路由表项组成,路由表项既可以手动设置静态路由,也可以通过路由协议自动生成动态路由。

名称 说明
路径
route
路由器转发数据包的路径
路由选择
routing
路由器为数据包选择路径的过程。完成路由选择后,把数据包转发出去的过程,叫做转发(forwarding)
路由表项
routing table entry
路由器在路由选择时参考的信息,有目的地址和下一跳组成
路由表
routing table
路由表项的汇总,路由器进行路由选择时需要参考的内容

路由选择在网络层完成,过程如下:

什么是转发

路由选择的过程需要根据目的 IP 地址的信息,判断将数据包转发到哪个网络。路由器的一个接口对应一个网络,发送到不同网络,是指路由器从某个接口收到数据,然后从另外的接口发送出去。

把数据包从接收接口到发送接口的发送过程叫做转发。

路由表包含的信息

路由表包含路由选择的必要信息,主要内容如下:

  • 目的 IP 地址:IP 包的目的地址。
  • 子网掩码:表示目的 IP 地址有多少位是网络位。
  • 网关:IP 包下一跳的 IP 地址。
  • 网络接口:IP 包从哪个路由器接口发送出去。
  • 度量值:当有多条到达目的地的不同路径时,度量值越小表示优先级越高。

五个内容组成一条路由表项。

路由器的功能

功能 说明
路由信息管理 管理静态路由和动态路由,从相邻路由器处获得路由更新信息,向相邻路由器发送路由更新信息
对分组进行分类 处理、队列以及判断分组是否可以转发。对比比较列表和分组,执行相关控制操作
三层交换 封装用于输出的二层数据,计算三层的校验总和,更新 TTL 以及 HOP 数
管理、计费、收集统计信息 接口的统计信息、Telnet、SNMP、ping、trace route、HTTP

静态路由

什么是静态路由

手动在路由器上设置的路由表项就叫做静态路由。

路由器在收到数据包时,会识别目的 IP 地址的网络号,来查询路由表的路由条目,根据最长匹配的路由条目,来判断应该从哪个接口转发数据包。路由表中有匹配的路由条目才会发送数据,无匹配的路由条目则直接丢弃。

路由表

路由表由路由条目组成,路由条目包含目的地址、下一跳和出接口等。

目的地址(Destination/Mask)表示目的网段地址或目的 IP 地址。目的地址既可以是直连在路由器接口上的网段地址,也可以是其它路由器上的网段地址或 IP 地址。

下一跳/出接口(NextHop/Interface)表示转发目的地址的数据包时,下一跳设备的接口 IP 地址,或者是将数据包从哪个接口转发出去。

协议类型(Proto/Protocol简写)表示路由条目的获取方式,一共有三种方式。

直连路由:路由器直接连接的路由条目,只要接口配置了 IP 地址,接口状态正常,就会自动生成对应的直连路由。

静态路由:通过命令手动添加的路由条目就是静态路由。

动态路由:通过路由协议从相邻路由器动态学习到的路由条目。

优先级( Pre / Preference 简写 )表示有多条去往同一个目的地址的路由条目,根据路由条目的类型,选择优先级最高的路由条目添加到路由表里面。

路由优先级的值越小,代表这种类型的路由优先级越高。

路径开销(Cost)表示通过同一种路由类型学习到多条去往同一个目的地址的路由条目,选择路径开销最小的路由条目添加到路由表里面。

直连路由

直连路由是唯一一种自动向路由表中添加路由条目。这种路由条目指向的目的网络是路由器接口直连的网络,直连路由的路由优先级和路径开销值都是 0。

为了保障直连路由的可用性,路由器只会把状态正常的接口所连接的网络,作为直连路由放入自己的路由表中。

静态路由

默认情况下,路由器只会自动生成直连路由。对于非直连网络,路由器并不知道要如何转发才能到达非直连网络。这时,我们就可以手动添加静态路由,告诉路由器如何转发去往某个网络的数据包。

静态路由的默认路由优先级为 60,还可以手动调整静态路由的优先值。静态路由的路径开销值是 0。路由器静态路由配置命令:

1
ip route-static destination-address mask-length nexthop-address

通过目的地址相同、下一跳或出接口不同的两条静态路由实现数据流量的负载分担,路由器会同时使用这两条静态路由条目转发数据包。但是在实际网络环境中,不推荐使用,因为数据报文往返路径不对称,会导致上层应用受影响。

通过目的地址相同、路由优先级不同的两条静态路由实现路由备份,当优先级高的路由条目出现问题时,路由器就会使用另一条优先级低的路由条目来转发数据包。

优点:

  • 对比动态路由,静态路由条目不会被自动删除,路由条目更稳定;
  • 只要手动添加,就会出现对应的静态路由,路由器也会使用这条静态路由转发数据包,路由条目更可控;
  • 配置去往某个网络的静态路由,只需要在路由器上添加一条简单的命令就可以实现,更容易部署。

缺点:

  • 在越大型网络中,配置和维护路由协议的工作量越大,出差的概率就越大。在大型网络中,静态路由只能作为动态路由的补充,因为静态路由的扩展性差。
  • 动态路由可以自动删除失效的动态路由条目。而静态路由无法反映拓扑变化,必须进行手动干预删除失效静态路由,否则路由器仍会按照配置的静态路由进行数据包转发。

默认路由

路由器只能转发有路由条目的数据包,对于网络未知的数据包,只能选择丢弃。实际上,我们也不可能知道所有网站或者应用程序的 IP 地址,需要使用一种特殊的路由条目解决这个问题。

路由转发的最长匹配原则是:当匹配目的 IP 地址的路由条目有多条时,路由器会选择子网掩码最长的路由条目,也就是最精确的路由条目来转发数据包。

我们通常会配置一条 0.0.0.0/0 的静态路由,根据最长匹配原则,可以匹配任何目的 IP 地址的数据包,保证任何数据包都能被转发出去;同时,只要路由器上还有任何一条可以匹配目的 IP 地址的路由条目,这条路由条目一定比 0.0.0.0/0 更精确,于是路由器会用更精确的路由条目来转发数据包。这就是静态默认路由,也是静态路由的一种。

一般家用路由器上除了本地直连路由外就只设置个默认路由,把去往互联网的流量都转发给运营商的路由器。

网关和默认网关

两个网络之间要实现通信,必须要通过网关。网关通常位于有路由功能的设备上,网关的 IP 地址可以是路由器的某个接口的 IP 地址,也可以是三层交换机 VLAN 端口的 IP 地址。

一台主机可以由多个网关,当一台主机找不到可用的网关时,数据包可以发送给默认网关。其实主机上配置的默认网关就是默认路由。

默认网关

如果路由表中不存在满足条件的表项,那么会根据路由表中的默认路由表项进行转发。默认路由表项的 IP 地址是0.0.0.0,子网掩码是0.0.0.0,即0.0.0.0/0。默认路由表项又叫做默认网关。如果路由表中不存在默认网关,那么路由器会告知错误,并丢弃这个数据包。

动态路由

当网络规模越来越大,路由器的数量越来越多时,通过手动配置路由表项是不可能的,这就要使用动态路由协议,在路由器之间交换信息自动生成路由表项。

如果网络使用动态路由,需要消耗一定的时间从其它路由器获取路由信息,路由表在这个过程中会逐渐增大,最终所有的路由器都获取到完整的路由表,这个过程叫做收敛。路由表从初始状态到收敛完成花费的时间叫做收敛时间,收敛时间越短,网络越稳定。通常,路由器数量越多,收敛时间越长,同时收敛时间还跟路由算法有关。算法不同,收敛时间的长短也不同。

使用动态路由时,有三种情况会发送路由器之间的路由信息交互:

  • 首次运行动态路由协议时
  • 网络中添加新的路由器或新的链路时
  • 网络中路由器离线或链路端口导致网络故障时

实战演练

静态路由实验

实验拓扑图

实验要求

  • 使用 ENSP 模拟器
  • PC1 能 ping 通 PC2
  • PC1 和 RT1 使用网段 1 互联:192.168.1.0/24
  • PC2 和 RT2 使用网段 2 互联:192.168.2.0/24
  • RT1 和 RT2 使用网段 3 互联:192.168.3.0/24

实验步骤

  1. 分配 IP 地址,并把 IP 地址配置到 PC 和 RT 的接口上。

PC1 的E0/0/1口配置192.168.1.1/24

PC2 的E0/0/1口配置192.168.2.2/24

RT1 的G0/0/1口配置192.168.1.10/24,RT1 的G0/0/0口配置192.168.3.10/24

RT2 的G0/0/1口配置192.168.2.20/24,RT2 的G0/0/0口配置192.168.3.20/24

  1. PC1 分别ping网段1、网段2、网段3 的 IP 地址,结果只能ping通同网段的192.168.1.10,其余不同网段的 IP 地址都无法ping通。其它主机和路由器也是只能ping通同网段的 IP 地址。
  1. 打通从 PC1 到 PC2 的路由,即 PC1 配置默认网关,RT1 配置到达192.168.2.0/24网段的静态路由。
  1. PC1 还是只能ping通同网段的192.168.1.10,其余不同网段的 IP 地址都无法ping通。检查各个设备的路由表,发现 PC1 的报文可以发送到 PC2,但是 PC2 和 RT2 没有回程路由,即 PC2 和 RT2 没有到达 PC1 的路由表项,返程报文无法到达 PC1。
  1. 打通从 PC1 到 PC2 的回程路由,即 PC2 配置默认网关,RT2 配置到达192.168.1.0/24网段的静态路由。
  1. PC1 ping PC2 成功,并使用tracert命令查看网络路径。

实验总结

配置路由时,需要在通信双方都进行配置,不要忘记配置回程路由。

浮动静态路由实验

实验拓扑图

实验要求

  • 使用 ENSP 模拟器
  • 在静态路由实验的基础上,新增一条网线连接 RT1 和 RT2 ,使用网段 4:192.168.4.0/24
  • 配置浮动静态路由
  • 配置等价静态路由

实验步骤

  1. RT1 的GE0/0/2口和 RT2 的GE0/0/2口分别配置网段 4 的 IP 地址。
  1. 通过修改静态路由优先级,使一条路由成为备份条目的路由,这就是浮动静态路由。在 RT1 上配置从192.168.4.0/24192.168.2.0/24的浮动静态路由,并且优先级设置为 50。

新增浮动路由前,查看 RT1 上192.168.2.0/24的路由条目。

新增浮动路由后,查看 RT1 上192.168.2.0/24的路由条目。

路由优先级的值越小,静态路由的优先级越高,静态路由的默认路由优先级为 60。因此新增优先级为 50 的静态路由为主路由条目,原来优先级为 60 的静态路由为备份路由。

  1. 当一台路由器上有两条不同的路径去往同一个网络的优先级相同的静态路由时,路由器就会同时使用这两条路由来转发流量,这就是等价静态路由。在 RT1 上配置从192.168.4.0/24192.168.2.0/24的等价静态路由,即使用默认优先级的值 60。

ACL

ACL(Access Control List,访问控制列表),是由一系列规则组成的集合。所谓规则,是指描述报文匹配条件的判断语句,这些条件,可以是报文的源地址、目的地址、端口号等。

ACL 本质上是一种报文过滤器,规则就是过滤器的滤芯。设备基于这些规则进行报文匹配,可以过滤出特定的报文,并根据应用 ACL 的业务模块的处理策略来允许或阻止该报文通过。

基于过滤出的报文,我们能够做到阻塞攻击报文、为不同类报文流提供差分服务、对 Telnet 登录/FTP 文件下载进行控制等等,从而提高网络环境的安全性和网络传输的可靠性。

典型的 ACL 应用组网场景:

某企业为保证财务数据安全,禁止研发部门访问财务服务器,但总裁办公室不受限制。

实现方式:在Interface1的入方向上部署 ACL,禁止研发部门访问财务服务器的报文通过。Interface2上无需部署 ACL,总裁办公室访问财务服务器的报文默认允许通过。

保护企业内网环境安全,防止 Internet 病毒入侵。

实现方式:在Interface3的入方向上部署 ACL,将病毒经常使用的端口予以封堵。

ACL的基本原理

ACL 由一系列规则组成,通过将报文与 ACL 规则进行匹配,设备可以过滤出特定的报文。

ACL编号:用于标识 ACL,表明该 ACL 是数字型 ACL。

根据 ACL 规则功能的不同,ACL 被划分为基本 ACL、高级 ACL、二层 ACL 和用户 ACL 这几种类型,每类 ACL 编号的取值范围不同。

规则:即描述报文匹配条件的判断语句。

规则编号:用于标识 ACL 规则。可以自行配置规则编号,也可以由系统自动分配。

ACL 规则的编号范围是0~4294967294,所有规则均按照规则编号从小到大进行排序。所以,上图中的rule 5排在首位,而规则编号最大的rule 4294967294排在末位。系统按照规则编号从小到大的顺序,将规则依次与报文匹配,一旦匹配上一条规则即停止匹配。

动作:包括permit/deny两种动作,表示允许/拒绝。

匹配项:ACL 定义了极其丰富的匹配项。除了图中的源地址和生效时间段,ACL 还支持很多其他规则匹配项。例如,二层以太网帧头信息(如源 MAC、目的 MAC、以太帧协议类型)、三层报文信息(如目的地址、协议类型)以及四层报文信息(如 TCP/UDP 端口号)等。

ACL的匹配机制

设备将报文与 ACL 规则进行匹配时,遵循“一旦命中立即停止匹配”的机制。

首先系统会查找设备上是否配置了ACL。

  • 如果ACL不存在,则返回ACL匹配结果为:不匹配。
  • 如果ACL存在,则查找设备是否配置了ACL规则。
  • 如果匹配上了permit规则,则停止查找规则,并返回ACL匹配结果为:匹配(允许)。
  • 如果匹配上了deny规则,则停止查找规则,并返回ACL匹配结果为:匹配(拒绝)。
  • 如果未匹配上规则,则继续查找下一条规则,以此循环。如果一直查到最后一条规则,报文仍未匹配上,则返回ACL匹配结果为:不匹配。
  • 如果规则不存在,则返回ACL匹配结果为:不匹配。
  • 如果规则存在,则系统会从ACL中编号最小的规则开始查找。

ACL的分类

基于ACL标识方法的划分

  • 数字型 ACL:传统的 ACL 标识方法。创建 ACL 时,指定一个唯一的数字标识该 ACL。
  • 命名型 ACL:通过名称代替编号来标识 ACL。

用户在创建 ACL 时可以为其指定编号,不同的编号对应不同类型的 ACL。同时,为了便于记忆和识别,用户还可以创建命名型 ACL,即在创建 ACL 时为其设置名称。命名型 ACL,也可以是“名称数字”的形式,即在定义命名型 ACL 时,同时指定 ACL 编号。如果不指定编号,系统则会自动为其分配一个数字型 ACL 的编号。

基于ACL规则定义方式的划分

基本ACL

仅使用报文的源IP地址、分片信息和生效时间段信息来定义规则。编号范围 2000~2999。

1
2
3
[Huawei] acl 2001
# 在 ACL2001 中配置规则,允许源 IP 地址是 192.168.1.3 主机地址的报文通过。
[Huawei-acl-basic-2001]rule permit source 192.168.1.3 0

高级ACL

既可使用 IPv4 报文的源 IP 地址,也可使用目的 IP 地址、IP 协议类型、ICMP 类型、TCP 源/目的端口、UDP 源/目的端口号、生效时间段等来定义规则。编号范围 3000~3999。

1
2
3
4
[Huawei]acl 3001
# 在 ACL3001 中配置规则,允许源 IP 地址是 192.168.1.3 的主机
# 且目的 IP 地址是 192.168.2.0/24 网段地址的 ICMP 报文通过。
[Huawei-acl-adv-3001]rule permit icmp source 192.168.1.3 0 destination 192.168.2.00.0.0.255

二层ACL

使用报文的以太网帧头信息来定义规则,如根据源 MAC 地址、目的 MAC 地址、二层协议类型等。编号范围 4000~4999。

1
2
3
4
[Huawei]acl 4001
# 在 ACL4001 中配置规则,允许目的 MAC 地址是 0000-0000-0001、
# 源 MAC 地址是 0000-0000-0002 的 ARP 报文(二层协议类型值为0x0806)通过。
[Huawei-acl-L2-4001]rule permit destination-mac 0000-0000-0001 source-mac 0000-0000-0002|2-protocol 0x0806

用户自定义ACL

可根据报文偏移位置和偏移量来定义规则。编号范围 5000~5999。

用户ACL

既可使用 IPv4 报文的源 IP 地址,也可使用目的 IP 地址、IP 协议类型、ICMP 类型、TCP 源端口/目的端口、UDP 源端口/目的端口号等来定义规则。编号范围 6000~6031。

1
2
3
4
[Huawei] acl 6000
# 在 ACL6000 中配置规则,
# 允许所有 Portal 用户可以免认证访问 IP 地址为 10.1.1.1/24 的网络。
[Huawei-acl-ucl-6000]rule permit ip destination 10.1.1.1 255.255.255.0
ACL类别 规则定义描述 编号范围
基本ACL 仅使用报文的源IP地址、分片标记和时间段信息来定义规则。 2000~2999
高级ACL 既可使用报文的源IP地址,也可使用目的地址、IP优先级、ToS、DSCP、IP协议类型、ICMP类型、TCP源端口/目的端口、UDP源端口/目的端口号等来定义规则。 3000~3999
二层ACL 可根据报文的以太网帧头信息来定义规则,如根据源MAC地址、目的MAC地址、以太帧协议类型等。 4000~4999
用户自定义ACL 可根据报文偏移位置和偏移量来定义规则。 5000~5999
用户ACL 既可使用IPv4报文的源IP地址或源UCL(User Control List)组,也可使用目的地址或目的UCL组、IP协议类型、ICMP类型、TCP源端口/目的端口、UDP源端口/目的端口号等来定义规则。 6000~9999

高级 ACL 可以定义比基本 ACL 更准确、更丰富、更灵活的规则,所以高级 ACL 的功能更加强大。

步长

步长,是指系统自动为 ACL 规则分配编号时,每个相邻规则编号之间的差值。也就是说,系统是根据步长值自动为 ACL 规则分配编号的。

1
2
3
4
5
6
7
8
9
10
11
12
13
[HUAWEI-acl-basic-2000]display acl 2000
Basic ACL 2000,3 rules
ACL’s step is 5
rule 5 permit source 1.1.1.0 0.0.0.255(match-counter 0)
rule 10 permit source 2.2.2.0 0.0.0.255(match-counter 0)
rule 15 permit source 3.3.3.0 0.0.0.255(match-counter 0)

[HUAWEI-acl-basic-2000]step 2
[HUAWEI-acl-basic-2000]display acl 2000
ACL’s step is 5
rule 2 permit source 1.1.1.0 0.0.0.255(match-counter 0)
rule 4 permit source 2.2.2.0 0.0.0.255(match-counter 0)
rule 6 permit source 3.3.3.0 0.0.0.255(match-counter 0)

ACL 的缺省步长值是 5。通过display acl acl-number命令,可以查看 ACL 规则、步长等配置信息。通过step number命令,可以修改 ACL 步长值。

实际上,设置步长的目的,是为了方便在 ACL 规则之间插入新的规则。

假设,一条 ACL 中,已包含了下面三条规则 5、10、15。如果你希望源 IP 地址为1.1.1.3的报文也被禁止通过,该如何处理呢?

1
2
3
rule 5 deny source 1.1.1.1 0  //表示禁止源IP地址为1.1.1.1的报文通过                  
rule 10 deny source 1.1.1.2 0 //表示禁止源IP地址为1.1.1.2的报文通过
rule 15 permit source 1.1.1.0 0.0.0.255 //表示允许源IP地址为1.1.1.0网段的报文通过

我们来分析一下。由于 ACL 匹配报文时遵循“一旦命中即停止匹配”的原则,所以源 IP 地址为1.1.1.11.1.1.2的报文,会在匹配上编号较小的rule 5rule 10后停止匹配,从而被系统禁止通过;而源 IP 地址为1.1.1.3的报文,则只会命中rule 15,从而得到系统允许通过。要想让源 IP 地址为1.1.1.3的报文也被禁止通过,我们必须为该报文配置一条新的deny规则。

1
2
3
4
rule 5 deny source 1.1.1.1 0  //表示禁止源IP地址为1.1.1.1的报文通过                  
rule 10 deny source 1.1.1.2 0 //表示禁止源IP地址为1.1.1.2的报文通过
rule 11 deny source 1.1.1.3 0 //表示禁止源IP地址为1.1.1.3的报文通过
rule 15 permit source 1.1.1.0 0.0.0.255 //表示允许源IP地址为1.1.1.0网段的报文通过

rule 10rule 15之间插入rule 11后,源 IP 地址为1.1.1.3的报文,就可以先命中rule 11而停止继续往下匹配,所以该报文将会被系统禁止通过。

试想一下,如果这条 ACL 规则之间间隔不是 5,而是 1(rule 1、rule 2、rule 3…),这时再想插入新的规则,只能先删除已有的规则,然后再配置新规则,最后将之前删除的规则重新配置回来。如果这样做,那付出的代价可真是太大了!

所以,通过设置 ACL 步长,为规则之间留下一定的空间,后续再想插入新的规则,就非常轻松了。

VLAN与VLANIF的区别

通俗的说,VLAN 就是一个二层的接口。

VLANIF就是创建三层接口,可以在上面配置 IP,通常这个接口地址作为 vlan 下面用户的网关。

实战

某公司通过交换机实现各部门之间的互连。要求只允许公司内网用户可以访问内网中的财务服务器,外网用户不允许访问。

1、配置接口加入 VLAN,并配置 VLANIF 接口的 IP 地址

将GE1/0/1~GE1/0/3分别加入VLAN10、20、30,这三个vlan中,也就是给公司三个部门各分配一个vlan。

GE2/0/1加入VLAN100,并配置各VLANIF接口的IP地址,也就是内网财务服务器的端口单独加一个vlan。

1
2
3
4
5
6
7
8
9
<HUAWEI>system-view
[HUAWEI]sysname Switch
[Switch]vlan batch 10 20 30 100
[Switch]interface gigabitethernet 1/0/1
[Switch-gigabitethernet-1/0/1]port link-type trunk
[Switch-gigabitethernet-1/0/1]port trunk allow-pass vlan 10
[Switch-gigabitethernet-1/0/1]quit
[Switch]interface vlanif 10
[Switch-Vlanif10]ip address 10.164.1.1 255.255.255.0
1
2
3
4
5
6
[Switch]interface gigabitethernet 1/0/2
[Switch-gigabitethernet-1/0/2]port link-type trunk
[Switch-gigabitethernet-1/0/2]port trunk allow-pass vlan 20
[Switch-gigabitethernet-1/0/2]quit
[Switch]interface vlanif 20
[Switch-Vlanif20]ip address 10.164.2.1 255.255.255.0
1
2
3
4
5
6
[Switch]interface gigabitethernet 2/0/1
[Switch-gigabitethernet-2/0/1]port link-type trunk
[Switch-gigabitethernet-2/0/1]port trunk allow-pass vlan 100
[Switch-gigabitethernet-2/0/1]quit
[Switch]interface vlanif 100
[Switch-Vlanif100]ip address 10.164.4.1 255.255.255.0

2、配置ACL

创建高级 ACL 3002 并配置 ACL 规则,允许位于内网的总裁办公室、市场部和研发部访问财务服务器的报文通过,拒绝外网用户访问财务服务器的报文通过。

1
2
3
4
5
6
[Switch]acl 30002
[Switch-acl-adv-3002]rule permit ip source 10.164.1.0 0.0.0.255 destination 10.164.4.4 0.0.0.0 //允许总裁办公室访问务服务器
[Switch-acl-adv-3002]rule permit ip source 10.164.2.0 0.0.0.255 destination 10.164.4.4 0.0.0.0 //允许市场部访问务服务器
[Switch-acl-adv-3002]rule permit ip source 10.164.3.0 0.0.0.255 destination 10.164.4.4 0.0.0.0 //允许研发部访问务服务器
[Switch-acl-adv-3002]rule deny ip destination 10.164.4.4 0.0.0.0 //禁止其他用户访问财务服务器
[Switch-acl-adv-3002]quit

3、配置基于ACL的流分类

配置流分类c_network,对匹配 ACL 3002 的报文进行分类。

1
2
[Switch]traffic classifier c_network // 创建流分类
[Switch-classifier-c_network]if-match acl 3002 // 将 acl 与流分类关联

4、配置流行为

配置流行为b_network,动作为允许报文通过(缺省值,不需配置)。

1
[Switch]traffic behavior b_network // 创建流行为

5、配置流策略

1
2
3
[Switch]traffic policy p_network // 创建流策略
// 配置流策略p_network,将流分类c_network与流行为b_network关联。
[Switch-trafficpolicy-p_network]classifier c_network behavior b_network

6、应用流策略

由于内外网访问服务器的流量均从接口GE2/0/1出口流向服务器,所以可以在GE2/0/1接口的出方向应用流策略p_network

1
2
[Switch]interface gigabitethernet 2/0/1
[Switch-gigabitethernet-2/0/1]traffic-policy p_network outbound

路由协议详解

路由的概念

在 TCP/IP 通信中,网络层的作用是实现终端的点对点通信。IP 协议通过 IP 地址将数据包发送给目的主机,能够让互联网上任何两台主机进行通信。IP 地址可以识别主机和路由器,路由器可以把全世界的网络连接起来。

什么是路由器

路由器可以连接多个网络。它有多个端口,分别连接不同的网络区域。通过识别目的 IP 地址的网络号,再根据路由表进行数据转发。路由器会维护一张路由表,通过路由表的信息,路由器才能正确的转发 IP 报文。

什么是路由

路由是网络设备根据 IP 地址对数据进行转发的操作。当路由器收到一个数据包时,它根据数据包的目的 IP 地址查询路由表,如果有匹配的路由条目,就根据查询结果将数据包转发出去,如果没有任何匹配的路由条目,则将数据包丢弃,这个过程就是 IP 路由。除了路由器,三层交换机、防火墙、负载均衡设备甚至主机等设备都可以进行路由操作,只要这个设备支持路由功能。

什么是路由表

为了将数据包发给目的节点,所有节点都维护着一张路由表。路由表是路由器通过各种途径获得的路由条目,每一个路由条目包含目的网段地址/子网掩码、路由协议、出接口、下一跳 IP 地址、路由优先级和度量值等信息。路由表记录 IP 包在下一跳应该发给哪个路由器。IP 包根据路由表在各个数据链路上传输。

路由表来源

一个实际的网络中,一台路由器通常包含多条路由条目,这些路由条目从不同的来源获取。路由表的来源可分为三类,分别是直连路由、静态路由和动态路由。

直连路由:路由器直接连接的路由条目,只要路由器接口配置了 IP 地址,接口状态正常,就会自动生成对应的直连路由。

静态路由:通过命令手动添加的路由条目就是静态路由。

动态路由:通过路由协议从相邻路由器动态学习到的路由条目。

路由优先级

不同来源的路由有不同的优先级,优先级的值越小,则路由的优先级就越高。当存在多条目的网段相同,但来源不同的路由时,具有最高优先级的路由成为最优路由,将被加入到路由表中,而其它路由则处于未激活状态,不显示在路由表中。

路由协议的默认优先级如下:

路由来源 优先级
直连路由 0
OSPF 10
静态路由 60
RIP 100
BGP 255

路由环路

路由环路是数据转发形成死循环,不能正确到达目的地。

路由环路的主要生成原因是配置错误的路由或网络规划错误导致。比如:在两台路由器上配置到相同目的地址的路由表项,下一跳互相指向对方,就会造成路由环路。另外某些动态路由协议配置不当,也有可能产生环路。

黑洞路由

一条路由条目,无论是静态的还是动态的,都需要关联到一个出接口,出接口指的是设备要到达目的网络是的出站接口。路由的出接口可以是这个设备的物理接口,如千兆网口,也可以是逻辑接口,如 VLAN 接口,或者是隧道接口等。其中有一种接口非常特殊,那就是Null接口,只有一个编号,那就是 0。Null0是一个系统保留的逻辑接口,当网络设备在转发数据包时,如果使用出接口Null0的路由,那么数据包将被丢弃,就像被扔进了一个黑洞里,因此出接口为Null0的路由条目又被称为黑洞路由。

黑洞路由是一种非常有用的路由条目,适用于如下场景:

  • 在网络使用中,按需将数据包指向黑洞路由,实现流量过滤。
  • 在已经部署路由汇总的网络中,用于防止数据转发出现环路。
  • 在部署了 NAT 的网络中,用于防止数据转发出现环路。
  • 在 BGP 网络中,用于发布特定网段的路由。

动态路由协议

静态路由是手动添加完成的。如果有 100 个网段,一个路由器就需要设置将近 100 条路由信息。网络使用过程中,不可避免的出现网段新增、删除、修改等情况,这些更新的路由信息需要在所有路由器上进行设置。还有一个不可忽视的问题,一旦某个路由器出现故障,数据传输无法自动绕过故障节点,只能通过手动设置才能恢复正常。

如果是使用动态路由,提前设置好路由协议,路由器之间会定期交换路由信息,路由器会知道网络中其它网段的信息,动态生成路由表。如果网络出现变化,网段需要增删改时,只需要在相应的路由器上配置动态路由即可。不需要像静态路由那样,在所有路由器上进行修改。对于大型网络,路由器个数较多时,主要使用动态路由协议。

即使网络上的节点出现故障,只要有一个可绕行的其它路径,那么路由器的路由表会自动重新设置,数据包也会自动选择这个路径。

采用路由协议后,网络拓扑结果变化的响应速度会大大提升。无论网络正常的增删改,还是异常的网络故障,相邻的路由器都会检测到变化,会把拓扑的变化通知网络中其它的路由器,使它们的路由表产生相应的变化。这个过程比手动对路由表的修改要快很多,也准确很多。

对于少于 10 个路由器的小型网络,静态路由或许已经能够满足需求,但是在大中型网络中,通常会使用动态路由协议,或者动态路由与静态路由协议相结合的方式来建设这个网络。

路由协议基本原理

路由器之间需要运行相同的路由协议,才能相互交换路由信息。每种路由协议都有自己的语言,即相应的路由协议报文。如果两台路由器启动了相同的路由协议,那么就有了相互通信的基础。不同的路由协议,有相同的目的,就是计算和维护路由表。通常工作过程包含 4 个阶段:

  • 邻居发现阶段:运行了路由协议后,路由器会主动把自己的网段信息发送给相邻的路由器。既可以使用广播发送路由协议消息,也可以单播将路由协议消息发送给指定的邻居路由器。
  • 交换路由信息阶段:发现邻居后,每台路由器都将自己的路由信息发送给相邻的路由器,相邻路由器又发送给下一个相邻的路由器。经过一段时间后,每台路由器都会收到网络中所有的路由信息。
  • 计算路由阶段:每一台路由器都会运行某种算法,计算出最终的路由表来。
  • 维护路由阶段:为了感知突然发送的网络故障,比如:设备故障或线路中断等,路由协议规定相邻两台路由器之间,应该周期性发送协议报文。如果路由器在一段时间内,没收到邻居发来的协议报文,就认为邻居路由器失效。

自治系统

随着 IP 网络的发展,网络规模已经很大了,无论哪种路由协议都不能完成全网的路由计算,因此网络分成了很多个自治系统(AS, Autonomous System)或路由选择域(Routing Domain)。自治系统可以制定自己的路由策略,并管理自治系统内进行具体路由控制的路由器集合。

每个自治系统都有一个唯一的自治系统编号,它的基本思路是希望通过不同的编号来区分不同的自治系统。通过路由协议和自治系统编号,路由器可以确定路由路径和路由信息的交换方式。某个自治系统缺乏足够的安全机制,就可以利用编号改变路径回避它。

自治系统的编号范围是1~65535,其中1~64511是注册的因特网编号,64512~65535是专用网络编号。

EGP 和 IGP

自治系统(路由选择域)内部动态路由使用的协议是域内路由协议,即 IGP(Interior Gateway Protocol)。而自治系统之间的路由控制使用的是域间路由协议,即 EGP(External Gateway Protocol)。

IGP 和 EGP 的关系,跟 IP 地址网络号和主机号的关系类似。根据 IP 地址的网络号在网络中进行路由选择,根据主机号在网段内部进行主机识别一样。既可以根据 EGP 在区域网络之间进行路由选择,也可以根据 IGP 在区域网络内部进行主机识别。

路由协议被分为 EGP 和 IGP 两个层次。没有 EGP 就不可能有世界上各个不同机构网络之间的通信,没有 IGP 机构内部也就不可能进行通信。

IGP 是指在同一个自治系统内交换路由信息的路由协议。RIP 、RIP2 、OSPF 属于 IGP 。IGP 的主要目的是发现和计算自治系统内的路由信息。

EGP 与 IGP 不同,EGP 用于连接不同的自治系统,并在不同自治系统间交换路由信息。EGP 的主要目的是使用路由策略和路由过滤等手段,控制路由信息在自治系统间的传播。BGP 属于 EGP。

动态路由协议类型

按照路由的算法和路由信息的交换方式,路由协议可以分为距离矢量(Distance-Vector,D-V)路由协议和链路状态(Link-State)路由协议。其中典型的距离矢量协议是 RIP ,典型的链路状态协议是 OSPF。

距离矢量路由协议

距离矢量路由协议指的是基于距离矢量的路由协议,RIP 是最具代表性的距离矢量路由协议。距离矢量这个概念包含两个关键的信息:距离和方向,其中距离是指到达目的网络的度量值(即所要经过路由器的个数),而方向指的是到达目的网络的下一跳设备。

每一台运行距离矢量路由协议的路由器会周期性的将自己的路由表通告出去,相邻的路由器收到路由信息并更新自己的路由表,再继续向其它直连的路由器通告路由信息,最终网络中的每台路由器都能知道各个网段的路由,这个过程称为路由的泛洪过程。

路由器之间互换目的网络的方向和距离的信息,并以这些信息更新路由表。这种方法在处理上比较简单,不过由于只有距离和方向的信息,所以当网络构造变得复杂时,在获得稳定的路由信息之前需要消耗一定时间(即路由收敛时间长),也极易发生路由循环等问题。

链路状态路由协议

运行链路状态路由协议的路由器会使用一些特殊的信息描述网络的拓扑结构和 IP 网段,这些信息被称为链路状态信息(LSA),所有路由器都会产生自己直连接口的链路状态信息。

路由器将网络中泛洪的链路状态信息搜集起来,存入一个数据库中,这个数据库就是 LSDB(链路状态数据库),LSDB 是对整个网络的拓扑结构及 IP 网段的描述,路由器拥有相同的 LSDB 。对于任何一台路由器,网络拓扑都完全一样。

接下来所有的路由器都基于 LSDB 使用最短路由优先算法进行计算,得到一棵已自己为根的、无环路的最短路径树,并将得到的路由加载到路由表中。

链路状态算法使用增量更新机制,只有当链路的状态发生变化时,才发送路由更新信息。

相比距离矢量路由协议,链路状态路由协议具有更大的扩展性和更快的收敛速度,但是它的算法消耗更多的内存和 CPU 处理能力。

路由协议的性能指标

不同的路由协议,有不同的特点。各个路由协议的性能指标体现如下:

  • 协议计算的正确性:是指路由协议的算法会不会产生错误的路由导致网络环路。不同的路由协议使用的算法不同,因此路由正确性也不相同。链路状态路由协议(如 OSPF )在算法上杜绝了产生路由环路的可能性,比距离矢量路由协议更优。
  • 路由收敛速度:路由收敛是指全网路由器的路由表达到一致状态。收敛速度快,意味着网络拓扑结构发生变化时,路由器能够更快的感知,并及时更新相应的路由信息。OSPF 、BGP 等协议的收敛速度快于 RIP 。
  • 协议所占的系统开销:路由器在运行路由协议时,需要消耗的系统资源,比如:CPU 、内存等。工作原理的不同,各个路由协议对系统资源的需求也不同。OSPF 路由技术的系统开销要大于 RIP 协议。
  • 协议自身的安全性:是指协议设计时,有没有考虑防止网络攻击。OSPF 、RIPv2 有相应的防止攻击的认证方法,而 RIPv1 没有。
  • 协议适用网络规模:不同路由协议所适用的网络规模、拓扑结构不同。RIP 协议有 16 跳的限制,所以只能应用在较小规模的网络中;而 OSPF 可以应用在几百台路由器的大规模网络中;BGP 能够管理全世界所有的路由器,其所管理的网络规模大小只受系统资源的限制。

主要路由协议

各种路由协议都需要使用 IP 来进行报文封装,但其细节有所不同。

RIP 协议是最早的路由协议,是为小型网络中提供简单易用的动态路由。RIP 协议报文采用 UDP 封装,端口号是 520 。由于 UDP 是不可靠的传输层协议,所以 RIP 协议需要周期性的广播协议报文来确保邻居收到路由信息。

OSPF 是目前应用最广泛的路由协议,可为大中型网络提供分层的、可靠的路由服务。OSFP 直接采用 IP 进行封装,所有协议报文都由 IP 封装后进行传输,协议号是 89 。IP 是尽力而为的网络层协议,本身是不可靠的,所以为了保证传输的可靠性,OSPF 采用了复杂的确认机制来保证传输可靠。

BGP 采用 TCP 来保证协议传输的可靠性,TCP 端口号是 179 。BGP 不需要自己设计可靠传输机制,降低了协议报文的复杂度和开销。

几种主要的路由协议表如下:

路由协议名称 下一层协议 方式 适用范围 循环检测
RIP UDP 距离矢量 IGP 不可以
RIPv2 UDP 距离矢量 IGP 不可以
OSPF IP 链路状态 IGP 可以
BGP TCP 路径矢量 EGP 可以

Wireshark 使用教程

wireshark 是非常流行的网络封包分析软件,简称小鲨鱼,功能十分强大。可以截取各种网络封包,显示网络封包的详细信息。

wireshark 是开源软件,可以放心使用。对应的,linux 下的抓包工具是tcpdump

Wireshark抓包原理

Wireshark 使用 WinPCAP 作为接口,直接与网卡进行数据报文交换。

Wireshark 使用的环境大致分为两种,一种是电脑直连网络的单机环境,另外一种就是应用比较多的网络环境,即连接交换机的情况。

  • 单机情况下,Wireshark 直接抓取本机网卡的网络流量;
  • 交换机情况下,Wireshark 通过端口镜像、ARP 欺骗等方式获取局域网中的网络流量。
  • 端口镜像:利用交换机的接口,将局域网的网络流量转发到指定电脑的网卡上。
  • ARP 欺骗:交换机根据 MAC 地址转发数据,伪装其他终端的 MAC 地址,从而获取局域网的网络流量。

Wireshark软件安装

软件下载路径:https://www.wireshark.org/

按照系统版本选择下载,下载完成后,按照软件提示一路Next安装。

Wireshark抓包示例

先介绍一个使用 wireshark 工具抓取ping命令操作的示例,可以上手操作感受一下抓包的具体过程。

1、打开 wireshark,主界面如下:

2、选择菜单栏上「捕获 -> 选项」,勾选 WLAN 网卡。这里需要根据各自电脑网卡使用情况选择,简单的办法可以看使用的 IP 对应的网卡。点击开始,启动抓包。

3、wireshark 启动后,wireshark 处于抓包状态中。

4、执行需要抓包的操作,如在 cmd 窗口下执行ping www.baidu.com

5、操作完成后相关数据包就抓取到了,可以点击「停止捕获分组」按钮。

6、为避免其他无用的数据包影响分析,可以通过在过滤栏设置过滤条件进行数据包列表过滤,获取结果如下。说明:ip.addr == 183.232.231.172 and icmp表示只显示 ICPM 协议且主机 IP 为183.232.231.172的数据包。说明:协议名称icmp要小写。

Wireshakr抓包界面介绍

Wireshark 的主界面包含6个部分:

  • 菜单栏:用于调试、配置
  • 工具栏:常用功能的快捷方式
  • 过滤栏:指定过滤条件,过滤数据包
  • 数据包列表:核心区域,每一行就是一个数据包
  • 数据包详情:数据包的详细数据
  • 数据包字节:数据包对应的字节流,二进制

说明:数据包列表区中不同的协议使用了不同的颜色区分。协议颜色标识定位在菜单栏「视图 –> 着色规则」。

WireShark 主要分为这几个界面

1. Display Filter(显示过滤器)

用于设置过滤条件进行数据包列表过滤。菜单路径:分析 –> Display Filters。

2. Packet List Pane(数据包列表)

显示捕获到的数据包,每个数据包包含编号,时间戳,源地址,目标地址,协议,长度,以及数据包信息。不同协议的数据包使用了不同的颜色区分显示。

3. Packet Details Pane(数据包详细信息)

在数据包列表中选择指定数据包,在数据包详细信息中会显示数据包的所有详细信息内容。数据包详细信息面板是最重要的,用来查看协议中的每一个字段。各行信息分别为

  1. Frame: 物理层的数据帧概况
  2. Ethernet II: 数据链路层以太网帧头部信息
  3. Internet Protocol Version 4: 互联网层IP包头部信息
  4. Transmission Control Protocol: 传输层T的数据段头部信息,此处是TCP
  5. Hypertext Transfer Protocol: 应用层的信息,此处是HTTP协议

从下图可以看到 wireshark 捕获到的 TCP 包中的每个字段。

4. Dissector Pane(数据包字节区)

报文原始内容。

Wireshark过滤器设置

wireshark 工具中自带了两种类型的过滤器,学会使用这两种过滤器会帮助我们在大量的数据中迅速找到我们需要的信息。

1.抓包过滤器

捕获过滤器的菜单栏路径为「捕获 –> 捕获过滤器」。用于在抓取数据包前设置。

设置如下。

ip host 183.232.231.172表示只捕获主机 IP 为183.232.231.172的数据包。获取结果如下:

2. 显示过滤器

显示过滤器是用于在抓取数据包后设置过滤条件进行过滤数据包。

通常是在抓取数据包时设置条件相对宽泛或者没有设置导致抓取的数据包内容较多时使用显示过滤器设置条件过滤以方便分析。

同样上述场景,在捕获时未设置抓包过滤规则直接通过网卡进行抓取所有数据包。

执行ping www.baidu.com获取的数据包列表如下:

观察上述获取的数据包列表,含有大量的无效数据。这时可以通过设置显示器过滤条件进行提取分析信息。ip.addr == 183.232.231.172,并进行过滤。

wireshark过滤器表达式的规则

1. 抓包过滤器语法

抓包过滤器类型Type(host、net、port)、方向Dir(src、dst)、协议Proto(ether、ip、tcp、udp、http、icmp、ftp等)、逻辑运算符(&&与、||或、!非)

  1. 协议过滤:直接在抓包过滤框中直接输入协议名即可。
  • tcp,只显示 TCP 协议的数据包列表
  • http,只查看 HTTP 协议的数据包列表
  • icmp,只显示 ICMP 协议的数据包列表
  1. IP 过滤
    1
    2
    3
    host 192.168.1.104
    src host 192.168.1.104
    dst host 192.168.1.104
  2. 端口过滤
    1
    2
    3
    port 80
    src port 80
    dst port 80
  3. 逻辑运算符
    1
    2
    3
    4
    5
    6
    // 抓取主机地址为192.168.1.80、目的端口为80的数据包
    src host 192.168.1.104 && dst port 80
    // 抓取主机为192.168.1.104或者192.168.1.102的数据包
    host 192.168.1.104 || host 192.168.1.102
    // 不抓取广播数据包
    !broadcast

2. 显示过滤器语法

  1. 比较操作符:==、!=、>、<、>=、<=
  2. 协议过滤:直接在Filter框中直接输入协议名即可。注意:协议名称需要输入小写。
  3. ip过滤
    ip.src ==112.53.42.42显示源地址为112.53.42.42的数据包列表
    ip.dst==112.53.42.42, 显示目标地址为112.53.42.42的数据包列表
    ip.addr==112.53.42.42显示源IP地址或目标IP地址为112.53.42.42的数据包列表
  4. 端口过滤
  • tcp.port==80, 显示源主机或者目的主机端口为 80 的数据包列表。
  • tcp.srcport==80, 只显示 TCP 协议的源主机端口为 80 的数据包列表。
  • tcp.dstport==80,只显示 TCP 协议的目的主机端口为 80 的数据包列表。
  1. http模式过滤
    http.request.method=="GET", 只显示 HTTP GET 方法的。
  2. 逻辑运算符为and/or/not
    过滤多个条件组合时,使用and/or。比如获取 IP 地址为192.168.0.104的 ICMP 数据包表达式为ip.addr == 192.168.0.104 and icmp
  3. 按照数据包内容过滤
    假设我要以 ICMP 层中的内容进行过滤,可以单击选中界面中的码流,在下方进行选中数据。 右键单击选中后出现如下界面 选中后在过滤器中显示如下 后面条件表达式就需要自己填写。如下我想过滤出data数据包中包含abcd内容的数据流。关键词是contains,完整条件表达式为data contains "abcd"

3. 常见用显示过滤需求及其对应表达式

  • 数据链路层:
    1
    2
    // 筛选源mac地址为04:f9:38:ad:13:26的数据包
    eth.src == 04:f9:38:ad:13:26
  • 网络层:
    1
    2
    3
    4
    // 筛选ip地址为192.168.1.1的数据包
    ip.addr == 192.168.1.1
    // 筛选192.168.1.0网段的数据
    ip contains "192.168.1"
  • 传输层:
    1
    2
    3
    4
    5
    6
    // 筛选端口为80的数据包
    tcp.port == 80
    // 筛选12345端口和80端口之间的数据包
    tcp.port == 12345 && tcp.port == 80
    // 筛选从12345端口到80端口的数据包
    tcp.srcport == 12345 && tcp.dstport == 80
  • 应用层:
    特别说明: httphttp.request表示请求头中的第一行(如GET index.jsp HTTP/1.1http.response表示响应头中的第一行(如HTTP/1.1 200 OK),其他头部都用http.header_name形式。
    1
    2
    3
    4
    // 筛选url中包含.php的http数据包
    http.request.uri contains ".php"
    // 筛选内容包含username的http数据包
    http contains "username"

Spring Boot统一日志框架

统一日志框架

日志框架的选择

市面上常见的日志框架有很多,它们可以被分为两类:日志门面(日志抽象层)和日志实现。

日志分类 描述 举例
日志门面(日志抽象层) 为 Java 日志访问提供一套标准和规范的 API 框架,其主要意义在于提供接口。 JCL(Jakarta Commons Logging)、SLF4j(Simple Logging Facade for Java)、jboss-logging
日志实现 日志门面的具体的实现 Log4j、JUL(java.util.logging)、Log4j2、Logback

通常情况下,日志由一个日志门面与一个日志实现组合搭建而成,Spring Boot 选用 SLF4J + Logback 的组合来搭建日志系统。

SLF4J 是目前市面上最流行的日志门面,使用 Slf4j 可以很灵活的使用占位符进行参数占位,简化代码,拥有更好的可读性。

Logback 是 Slf4j 的原生实现框架,它与 Log4j 出自一个人之手,但拥有比 log4j 更多的优点、特性和更做强的性能,现在基本都用来代替 log4j 成为主流。

SLF4J 的使用

在项目开发中,记录日志时不应该直接调用日志实现层的方法,而应该调用日志门面(日志抽象层)的方法。

在使用 SLF4J 记录日志时,我们需要在应用中导入 SLF4J 及日志实现,并在记录日志时调用 SLF4J 的方法,例如:

1
2
3
4
5
6
7
8
9
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class HelloWorld {
public static void main(String[] args) {
Logger logger = LoggerFactory.getLogger(HelloWorld.class);
//调用 sl4j 的 info() 方法,而非调用 logback 的方法
logger.info("Hello World");
}
}

SLF4J 作为一款优秀的日志门面或者日志抽象层,它可以与各种日志实现框架组合使用,以达到记录日志的目的,如下图。

SLF4J 与与日志实现配合使用方案
图1:SLF4J 的使用方案

从 SLF4J 官方给出的方案可以看出:

Logback 作为 Slf4j 的原生实现框架,当应用使用 SLF4J+Logback 的组合记录日志时,只需要引入 SLF4J 和 Logback 的 Jar 包即可;
Log4j 虽然与 Logback 出自同一个人之手,但是 Log4j 出现要早于 SLF4J,因而 Log4j 没有直接实现 SLF4J,当应用使用 SLF4J+Log4j 的组合记录日志时,不但需要引入 SLF4J 和 Log4j 的 Jar 包,还必须引入它们之间的适配层(Adaptation layer)slf4j-log4j12.jar,该适配层可谓“上有老下有小”,它既要实现 SLF4J 的方法,还有调用 Log4j 的方法,以达到承上启下的作用;
当应用使用 SLF4J+JUL 记录日志时,与 SLF4J+Log4j 一样,不但需要引入 SLF4J 和 JUL 的对应的 Jar 包,还要引入适配层 slf4j-jdk14.jar。
这里我们需要注意一点,每一个日志的实现框架都有自己的配置文件。使用 slf4j 记录日志时,配置文件应该使用日志实现框架(例如 logback、log4j 和 JUL 等等)自己本身的配置文件。

统一日志框架(通用)

通常一个完整的应用下会依赖于多种不同的框架,而且它们记录日志使用的日志框架也不尽相同,例如,Spring Boot(slf4j+logback),Spring(commons-logging)、Hibernate(jboss-logging)等等。那么如何统一日志框架的使用呢?

对此,SLF4J 官方也给出了相应的解决方案,如下图。

同一日志框架的使用
图2:统一日志框架的使用方案

从上图中可以看出,统一日志框架一共需要以下 3 步 :
排除应用中的原来的日志框架;
引入替换包替换被排除的日志框架;
导入 SLF4J 实现。

SLF4J 官方给出的统一日志框架的方案是“狸猫换太子”,即使用一个替换包来替换原来的日志框架,例如 log4j-over-slf4j 替换 Log4j(Commons Logging API)、jul-to-slf4j.jar 替换 JUL(java.util.logging API)等等。

替换包内包含被替换的日志框架中的所有类,这样就可以保证应用不会报错,但替换包内部实际使用的是 SLF4J API,以达到统一日主框架的目的。

统一日志框架(Spring Boot)

我们在使用 Spring Boot 时,同样可能用到其他的框架,例如 Mybatis、Spring MVC、 Hibernate 等等,这些框架的底层都有自己的日志框架,此时我们也需要对日志框架进行统一。

统一日志框架的使用一共分为 3 步,Soring Boot已经为用户完成了其中 2 步:引入替换包和导入 SLF4J 实现。

Spring Boot 的核心启动器 spring-boot-starter 引入了 spring-boot-starter-logging,使用 IDEA 查看其依赖关系,如下图。

Spring Boot 依赖关系图
图3:spring-boot-starter-logging 依赖关系图

从图 3 可知,spring-boot-starter-logging 的 Maven 依赖不但引入了 logback-classic (包含了日志框架 SLF4J 的实现),还引入了 log4j-to-slf4j(log4j 的替换包),jul-to-slf4j (JUL 的替换包),即 Spring Boot 已经为我们完成了统一日志框架的 3 个步骤中的 2 步。

SpringBoot 底层使用 slf4j+logback 的方式记录日志,当我们引入了依赖了其他日志框架的第三方框架(例如 Hibernate)时,只需要把这个框架所依赖的日志框架排除,即可实现日志框架的统一,示例代码如下。

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>org.apache.activemq</groupId>
<artifactId>activemq-console</artifactId>
<version>${activemq.version}</version>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>

日志配置及输出

默认配置

Spring Boot 默认使用 SLF4J+Logback 记录日志,并提供了默认配置,即使我们不进行任何额外配,也可以使用 SLF4J+Logback 进行日志输出。

常见的日志配置包括日志级别、日志的输入出格式等内容。

日志级别

日志的输出都是分级别的,当一条日志信息的级别大于或等于配置文件的级别时,就对这条日志进行记录。

常见的日志级别如下(优先级依次升高)。

序号 日志级别 说明
1 trace 追踪,指明程序运行轨迹。
2 debug 调试,实际应用中一般将其作为最低级别,而 trace 则很少使用。
3 info 输出重要的信息,使用较多。
4 warn 警告,使用较多。
5 error 错误信息,使用较多。
输出格式
我们可以通过以下常用日志参数对日志的输出格式进行修改,如下表。

序号 输出格式 说明
1 %d{yyyy-MM-dd HH:mm:ss, SSS} 日志生产时间,输出到毫秒的时间
2 %-5level 输出日志级别,-5 表示左对齐并且固定输出 5 个字符,如果不足在右边补 0
3 %logger 或 %c logger 的名称
4 %thread 或 %t 输出当前线程名称
5 %p 日志输出格式
6 %message 或 %msg 或 %m 日志内容,即 logger.info(“message”)
7 %n 换行符
8 %class 或 %C 输出 Java 类名
9 %file 或 %F 输出文件名
10 %L 输出错误行号
11 %method 或 %M 输出方法名
12 %l 输出语句所在的行数, 包括类名、方法名、文件名、行数
13 hostName 本地机器名
14 hostAddress 本地 ip 地址
示例 1
下面我们通过一个实例,来查看 Spring Boot 提供了哪些默认日志配置。

  1. 在 Spring Boot 中编写 Java 测试类,代码如下。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    package net.biancheng.www;
    import org.junit.jupiter.api.Test;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.boot.test.context.SpringBootTest;
    @SpringBootTest
    class SpringbootLoggingApplicationTests {
    Logger logger = LoggerFactory.getLogger(getClass());
    /**
    * 测试日志输出
    * SLF4J 日志级别从小到大trace>debug>info>warn>error
    */
    @Test
    void logTest() {
    //日志级别 由低到高
    logger.trace("trace 级别日志");
    logger.debug("debug 级别日志");
    logger.info("info 级别日志");
    logger.warn("warn 级别日志");
    logger.error("error 级别日志");
    }
    }
  2. 执行该测试,控制台输出如下图。

SpringBoot 日志级别
图1:Spring Boot 日志级别

通过控制台输出结果可知,Spring Boot 日志默认级别为 info,日志输出内容默认包含以下元素:
时间日期
日志级别
进程 ID
分隔符:—
线程名:方括号括起来(可能会截断控制台输出)
Logger 名称
日志内容
修改默认日志配置
我们可以根据自身的需求,通过全局配置文件(application.properties/yml)修改 Spring Boot 日志级别和显示格式等默认配置。

在 application.properties 中,修改 Spring Boot 日志的默认配置,代码如下。

#日志级别
logging.level.net.biancheng.www=trace
#使用相对路径的方式设置日志输出的位置(项目根目录目录\my-log\mylog\spring.log)
#logging.file.path=my-log/myLog
#绝对路径方式将日志文件输出到 【项目所在磁盘根目录\springboot\logging\my\spring.log】
logging.file.path=/spring-boot/logging
#控制台日志输出格式
logging.pattern.console=%d{yyyy-MM-dd hh:mm:ss} [%thread] %-5level %logger{50} - %msg%n
#日志文件输出格式
logging.pattern.file=%d{yyyy-MM-dd} === [%thread] === %-5level === %logger{50} === - %msg%n

执行测试代码,执行结果如下。

Spring Boot 日志自定义配置
图2:Spring Boot 日志修改默认配置

从图 2 可以看到,控制台中日志的输出格式与 application.properties 中的 logging.pattern.console 配置一致。

查看本地日志文件 spring.log,该文件日志输出内容如下图。

Spring Boot 本地日志文件
图3:本地日志文件 spring.log

从图 3 可以看到,本地日志文件中的日志输出格式与 application.properties 中 logging.pattern.file 配置一致。
自定义日志配置
在 Spring Boot 的配置文件 application.porperties/yml 中,可以对日志的一些默认配置进行修改,但这种方式只能修改个别的日志配置,想要修改更多的配置或者使用更高级的功能,则需要通过日志实现框架自己的配置文件进行配置。

Spring 官方提供了各个日志实现框架所需的配置文件,用户只要将指定的配置文件放置到项目的类路径下即可。

日志框架 配置文件
Logback logback-spring.xml、logback-spring.groovy、logback.xml、logback.groovy
Log4j2 log4j2-spring.xml、log4j2.xml
JUL (Java Util Logging) logging.properties

从上表可以看出,日志框架的配置文件基本上被分为 2 类:

普通日志配置文件,即不带 srping 标识的配置文件,例如 logback.xml;
带有 spring 表示的日志配置文件,例如 logback-spring.xml。

这两种日志配置文件在使用时大不相同,下面我们就对它们分别进行介绍。

普通日志配置文件
我们将 logback.xml、log4j2.xml 等不带 spring 标识的普通日志配置文件,放在项目的类路径下后,这些配置文件会跳过 Spring Boot,直接被日志框架加载。通过这些配置文件,我们就可以达到自定义日志配置的目的。

示例

  1. 将 logback.xml 加入到 Spring Boot 项目的类路径下(resources 目录下),该配置文件配置内容如下。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    <?xml version="1.0" encoding="UTF-8"?>
    <!--
    scan:当此属性设置为true时,配置文件如果发生改变,将会被重新加载,默认值为true。
    scanPeriod:设置监测配置文件是否有修改的时间间隔,如果没有给出时间单位,默认单位是毫秒当scan为true时,此属性生效。默认的时间间隔为1分钟。
    debug:当此属性设置为true时,将打印出logback内部日志信息,实时查看logback运行状态。默认值为false。
    -->
    <configuration scan="false" scanPeriod="60 seconds" debug="false">
    <!-- 定义日志的根目录 -->
    <property name="LOG_HOME" value="/app/log"/>
    <!-- 定义日志文件名称 -->
    <property name="appName" value="bianchengbang-spring-boot-logging"></property>
    <!-- ch.qos.logback.core.ConsoleAppender 表示控制台输出 -->
    <appender name="stdout" class="ch.qos.logback.core.ConsoleAppender">
    <!--
    日志输出格式:
    %d表示日期时间,
    %thread表示线程名,
    %-5level:级别从左显示5个字符宽度
    %logger{50} 表示logger名字最长50个字符,否则按照句点分割。
    %msg:日志消息,
    %n是换行符
    -->
    <layout class="ch.qos.logback.classic.PatternLayout">
    <pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread]**************** %-5level %logger{50} - %msg%n</pattern>
    </layout>
    </appender>
    <!-- 滚动记录文件,先将日志记录到指定文件,当符合某个条件时,将日志记录到其他文件 -->
    <appender name="appLogAppender" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <!-- 指定日志文件的名称 -->
    <file>${LOG_HOME}/${appName}.log</file>
    <!--
    当发生滚动时,决定 RollingFileAppender 的行为,涉及文件移动和重命名
    TimeBasedRollingPolicy: 最常用的滚动策略,它根据时间来制定滚动策略,既负责滚动也负责出发滚动。
    -->
    <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
    <!--
    滚动时产生的文件的存放位置及文件名称 %d{yyyy-MM-dd}:按天进行日志滚动
    %i:当文件大小超过maxFileSize时,按照i进行文件滚动
    -->
    <fileNamePattern>${LOG_HOME}/${appName}-%d{yyyy-MM-dd}-%i.log</fileNamePattern>
    <!--
    可选节点,控制保留的归档文件的最大数量,超出数量就删除旧文件。假设设置每天滚动,
    且maxHistory是365,则只保存最近365天的文件,删除之前的旧文件。注意,删除旧文件是,
    那些为了归档而创建的目录也会被删除。
    -->
    <MaxHistory>365</MaxHistory>
    <!--
    当日志文件超过maxFileSize指定的大小是,根据上面提到的%i进行日志文件滚动 注意此处配置SizeBasedTriggeringPolicy是无法实现按文件大小进行滚动的,必须配置timeBasedFileNamingAndTriggeringPolicy
    -->
    <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
    <maxFileSize>100MB</maxFileSize>
    </timeBasedFileNamingAndTriggeringPolicy>
    </rollingPolicy>
    <!-- 日志输出格式: -->
    <layout class="ch.qos.logback.classic.PatternLayout">
    <pattern>%d{yyyy-MM-dd HH:mm:ss} [ %thread ] ------------------ [ %-5level ] [ %logger{50} : %line ] -
    %msg%n
    </pattern>
    </layout>
    </appender>
    <!--
    logger主要用于存放日志对象,也可以定义日志类型、级别
    name:表示匹配的logger类型前缀,也就是包的前半部分
    level:要记录的日志级别,包括 TRACE < DEBUG < INFO < WARN < ERROR
    additivity:作用在于children-logger是否使用 rootLogger配置的appender进行输出,
    false:表示只用当前logger的appender-ref,true:
    表示当前logger的appender-ref和rootLogger的appender-ref都有效
    -->
    <!-- hibernate logger -->
    <logger name="net.biancheng.www" level="debug"/>
    <!-- Spring framework logger -->
    <logger name="org.springframework" level="debug" additivity="false"></logger>
    <!--
    root与logger是父子关系,没有特别定义则默认为root,任何一个类只会和一个logger对应,
    要么是定义的logger,要么是root,判断的关键在于找到这个logger,然后判断这个logger的appender和level。
    -->
    <root level="info">
    <appender-ref ref="stdout"/>
    <appender-ref ref="appLogAppender"/>
    </root>
    </configuration>
  2. 启动该项目并启动测试程序,结果如下。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
      .   ____          _            __ _ _
    /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
    ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
    \\/ ___)| |_)| | | | | || (_| | ) ) ) )
    ' |____| .__|_| |_|_| |_\__, | / / / /
    =========|_|==============|___/=/_/_/_/
    :: Spring Boot :: (v2.5.0)
    2021-05-24 14:51:11 [main]**************** INFO n.biancheng.www.SpringBootLoggingApplicationTests - Starting SpringBootLoggingApplicationTests using Java 1.8.0_131 on LAPTOP-C67MRMAG with PID 20080 (started by 79330 in D:\eclipse workSpace4\spring-boot-logging)
    2021-05-24 14:51:11 [main]**************** DEBUG n.biancheng.www.SpringBootLoggingApplicationTests - Running with Spring Boot v2.5.0, Spring v5.3.7
    2021-05-24 14:51:11 [main]**************** INFO n.biancheng.www.SpringBootLoggingApplicationTests - The following profiles are active: dev
    2021-05-24 14:51:13 [main]**************** INFO n.biancheng.www.SpringBootLoggingApplicationTests - Started SpringBootLoggingApplicationTests in 2.058 seconds (JVM running for 3.217)
    2021-05-24 14:51:13 [main]**************** DEBUG n.biancheng.www.SpringBootLoggingApplicationTests - debug 级别日志
    2021-05-24 14:51:13 [main]**************** INFO n.biancheng.www.SpringBootLoggingApplicationTests - info 级别日志
    2021-05-24 14:51:13 [main]**************** WARN n.biancheng.www.SpringBootLoggingApplicationTests - warn 级别日志
    2021-05-24 14:51:13 [main]**************** ERROR n.biancheng.www.SpringBootLoggingApplicationTests - error 级别日志

    带有 spring 标识的日志配置文件
    Spring Boot 推荐用户使用 logback-spring.xml、log4j2-spring.xml 等这种带有 spring 标识的配置文件。这种配置文件被放在项目类路径后,不会直接被日志框架加载,而是由 Spring Boot 对它们进行解析,这样就可以使用 Spring Boot 的高级功能 Profile,实现在不同的环境中使用不同的日志配置。
    示例

  3. 将 logback.xml 文件名修改为 logback-spring.xml,并将配置文件中日志输出格式的配置修改为使用 Profile 功能的配置。

  4. 配置内容修改前,日志输出格式配置如下。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <configuration scan="false" scanPeriod="60 seconds" debug="false">
    ......
    <appender name="stdout" class="ch.qos.logback.core.ConsoleAppender">
    <layout class="ch.qos.logback.classic.PatternLayout">
    <pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread]**************** %-5level %logger{50} - %msg%n</pattern>
    </layout>
    </appender>
    ......
    </configuration>
  5. 修改 logback-spring.xml 的配置内容,通过 Profile 功能实现在不同的环境中使用不同的日志输出格式,配置如下。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <configuration scan="false" scanPeriod="60 seconds" debug="false">
    ......
    <appender name="stdout" class="ch.qos.logback.core.ConsoleAppender">
    <layout class="ch.qos.logback.classic.PatternLayout">
    <!--开发环境 日志输出格式-->
    <springProfile name="dev">
    <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} ----> [%thread] ---> %-5level %logger{50} - %msg%n</pattern>
    </springProfile>
    <!--非开发环境 日志输出格式-->
    <springProfile name="!dev">
    <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} ==== [%thread] ==== %-5level %logger{50} - %msg%n</pattern>
    </springProfile>
    </layout>
    </appender>
    ......
    </configuration>
  6. 在 Spring Boot 项目的 application.yml 中,激活开发环境(dev)的 Profile,配置内容如下。

    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
    #默认配置
    server:
    port: 8080
    #切换配置
    spring:
    profiles:
    active: dev
    ---
    #开发环境
    server:
    port: 8081
    spring:
    config:
    activate:
    on-profile: dev
    ---
    #测试环境
    server:
    port: 8082
    spring:
    config:
    activate:
    on-profile: test
    ---
    #生产环境
    server:
    port: 8083
    spring:
    config:
    activate:
    on-profile: prod
  7. 启动 Spring Boot 并执行测试代码,控制台输出如下。

SpringProfile
图4:dev 环境日志输出结果

  1. 修改 appplication.yml 中的配置,激活测试环境(test)的 Profile,配置如下。
    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
    #默认配置
    server:
    port: 8080
    #切换配置
    spring:
    profiles:
    active: test
    ---
    #开发环境
    server:
    port: 8081
    spring:
    config:
    activate:
    on-profile: dev
    ---
    #测试环境
    server:
    port: 8082
    spring:
    config:
    activate:
    on-profile: test
    ---
    #生产环境
    server:
    port: 8083
    spring:
    config:
    activate:
    on-profile: prod
  2. 重启 Spring Boot 并执行测试代码,控制台输出如下。

Spring Boot Profile
图5:test 环境日志输出结果

git基础

基本概念

概念名称 描述
工作区(Workspace 就是在电脑里能看到的代码库目录,新增、修改的文件会提交到暂存区
暂存区(stageindex 用于临时存放文件的修改,实际上上它只是一个文件(.git/index),保存待提交的文件列表信息。
版本库/仓库(Repository Git的管理仓库,管理版本的数据库,记录文件/目录状态的地方,所有内容的修改记录(版本)都在这里。
服务端/远程仓库(originremote 服务端的版本库,专用的Git服务器,为多人共享提供服务,承担中心服务器的角色。本地版本库通过push指令把代码推送到服务端版本库。
本地仓库 用户机器上直接使用的的的版本库
分支(Branch 分支是从主线分离出去的“副本”,可以独立操作而互不干扰,仓库初始化就有一个默认主分支master
头(HEAD HEAD类似一个“指针”,指向当前活动 分支 的 最新版本。
提交(Commit 把暂存区的所有变更的内容提交到当前仓库的活动分支。
推送(Push 将本地仓库的版本推送到服务端(远程)仓库,与他人共享。
拉取(Pull 从服务端(远程)仓库获取更新到本地仓库,获取他人共享的更新。
获取(Fetch 从服务端(远程)仓库更新,作用同拉取(Pull),区别是不会自动合并。
冲突(Conflict 多人对同一文件的工作副本进行更改,并将这些更改合并到仓库时就会面临冲突,需要人工合并处理。
合并(Merge 对有冲突的文件进行合并操作,Git 会自动合并变更内容,无法自动处理的冲突内容会提示人工处理。
标签(Tags 标签指的是某个分支某个特定时间点的状态,可以理解为提交记录的别名,常用来标记版本。
master(或main 仓库的master分支,默认的主分支,初始化仓库就有了。Github 上创建的仓库默认名字为main
origin/master 表示远程仓库(origin)的master分支
origin/HEAD 表示远程仓库(origin)的最新提交的位置,一般情况等于origin/master

工作区/暂存区/仓库

🔸工作区(Workspace)就是在电脑里能看到的代码库目录,新增、修改的文件会提交到暂存区。在这里新增文件、修改文件内容,或删除文件。
🔸暂存区(stageindex)用于临时存放文件的修改,实际上上它只是一个文件(.git/index),保存待提交的文件列表信息。用git add命令将工作区的修改保存到暂存区。
🔸版本库/仓库(Repository仓库)Git 的管理仓库,管理版本的数据库,记录文件/目录状态的地方,所有内容的修改记录(版本)都在这里。就是工作区目录下的隐藏文件夹.git,包含暂存区、分支、历史记录等信息。用git commit命令将暂存区的内容正式提交到版本库。master为仓库的默认分支masterHEAD是一个“指针”指向当前分支的最新提交,默认指向最新的master

如上图,为对应本地仓库目录的结构关系。

  • 项目根目录下隐藏的.git目录就是 Git 仓库目录了,存放了所有 Git 管理的信息。
  • .git/config为该仓库的配置文件,可通过指令修改或直接修改。
  • index文件就是存放的暂存区内容。

Git基本流程

Git 的工作流程核心就下面几个步骤,掌握了就可以开始写 Bug 了。

  1. 准备仓库:创建或从服务端克隆一个仓库。
  2. 搬砖:在工作目录中添加、修改代码。
  3. 暂存(git add):将需要进行版本管理的文件放入暂存区域。
  4. 提交(git commit):将暂存区域的文件提交到Git仓库。
  5. 推送(git push):将本地仓库推送到远程仓库,同步版本库。
  6. 获取更新(fetch/pull):从服务端更新到本地,获取他人推送的更新,与他人协作、共享。
  • git commit -a指令省略了add到暂存区的步骤,直接提交工作区的修改内容到版本库,不包括新增的文件。
  • git fetch、git pull都是从远程服务端获取最新记录,区别是git pull多了一个步骤,就是自动合并更新工作区。
  • git checkout .、git checkout [file]会清除工作区中未添加到暂存区的修改,用暂存区内容替换工作区。
  • git checkout HEAD .、git checkout HEAD [file]会清除工作区、暂存区的修改,用HEAD指向的当前分支最新版本替换暂存区、工作区。
  • git diff用来对比不同部分之间的区别,如暂存区、工作区,最新版本与未提交内容,不同版本之间等。
  • git reset是专门用来撤销修改、回退版本的指令,替代上面checkout的撤销功能。

Git状态

Git 在执行提交的时候,不是直接将工作区的修改保存到仓库,而是将暂存区域的修改保存到仓库。要提交文件,首先需要把文件加入到暂存区域中。因此,Git 管理的文件有三(+2)种状态:

  • 未跟踪:新添加的文件,或被移除跟踪的文件,未建立跟踪,通过git add添加暂存并建立跟踪。
  • 未修改:从仓库签出的文件默认状态,修改后就是“已修改”状态了。
  • 已修改(modified):文件被修改后的状态。
  • 已暂存(staged):修改、新增的文件添加到暂存区后的状态。
  • 已提交(committed):从暂存区提交到版本库。

Git的配置文件

Git 有三个主要的配置文件:三个配置文件的优先级是1 < 2 < 3

  1. 系统全局配置(--system):包含了适用于系统所有用户和所有仓库(项目)的配置信息,存放在 Git 安装目录下C:\Program Files\Git\etc\gitconfig
  2. 用户全局配置(--system):当前系统用户的全局配置,存放用户目录:C:\Users\[系统用户名]\.gitconfig
  3. 仓库/项目配置(--local):仓库(项目)的特定配置,存放在项目目录下.git/config
1
2
3
4
5
6
7
8
9
10
11
12
#查看git配置
git config --list
git config -l

#查看系统配置
git config --system --list

#查看当前用户(global)全局配置
git config --list --global

#查看当前仓库配置信息
git config --local --list

仓库的配置是上面多个配置的集合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ git config --list
$ git config -l
diff.astextplain.textconv=astextplain
http.sslbackend=openssl
http.sslcainfo=C:/Program Files/Git/mingw64/ssl/certs/ca-bundle.crt
core.autocrlf=true
core.fscache=true
core.symlinks=false
pull.rebase=false
credential.helper=manager-core
credential.https://dev.azure.com.usehttppath=true
init.defaultbranch=master
user.name=Kanding
user.email=123anding@163.com

配置-初始化用户

当安装 Git 后首先要做的事情是配置你的用户信息—— 告诉Git你是谁。配置用户名、邮箱地址,每次提交文件时都会带上这个用户信息,查看历史记录时就知道是谁干的了。

配置用户信息:

1
2
3
4
5
6
7
$ git config --global user.name "Your Name"
$ git config --global user.email "email@example.com"
# 配置完后,看看用户配置文件:
$ cat 'C:\Users\Kwongad\.gitconfig'
[user]
name = Kanding
email = 123anding@163.com
  • user.name为用户名,user.email为邮箱。
  • --globalconfig的参数,表示用户全局配置。如果要给特定仓库配置用户信息,则用参数--local配置即可,或直接在仓库配置文件.git/config里修改。

配置-忽略.gitignore

在工作区根目录下创建.gitignore文件,文件中配置不需要进行版本管理的文件、文件夹。.gitignore文件本身是被纳入版本管理的,可以共享。有如下规则:

  • #符号开头为注释。
  • 可以使用 Linux 通配符。
  • 星号(*)代表任意多个字符,
  • 问号(?)代表一个字符,
  • 方括号([abc])代表可选字符范围,
  • 大括号({string1,string2,...})代表可选的字符串等。
  • 感叹号(!)开头:表示例外规则,将不被忽略。
  • 路径分隔符(/f)开头:,表示要忽略根目录下的文件f
  • 路径分隔符(f/)结尾:,表示要忽略文件夹f下面的所有文件。
1
2
3
4
5
6
#为注释
*.txt #忽略所有“.txt”结尾的文件
!lib.txt #lib.txt除外
/temp #仅忽略项目根目录下的temp文件,不包括其它目录下的temp,如不包括“src/temp”
build/ #忽略build/目录下的所有文件
doc/*.txt #会忽略 doc/notes.txt 但不包括 doc/server/arch.txt

Git使用入门

创建仓库

创建本地仓库的方法有两种:

  • 一种是创建全新的仓库:git init,会在当前目录初始化创建仓库。
  • 另一种是克隆远程仓库:git clone [url]
1
2
3
4
5
# 进入项目目录
cd test-ui

# 开始初始化项目,也可指定目录:git init [文件目录]
git init

📢注意:Git 指令的执行,都需在仓库目录下。

创建完多出了一个被隐藏的.git目录,这就是本地仓库 Git 的工作场所。

克隆远程仓库,如在 github 上创建的仓库https://github.com/xxx/test-ui.git

1
2
3
4
5
6
7
8
$ git clone 'https://github.com/xxx/test-ui.git'
Cloning into 'test-ui'...
remote: Enumerating objects: 108, done.
remote: Counting objects: 100% (108/108), done.
remote: Compressing objects: 100% (60/60), done.
remote: Total 108 (delta 48), reused 88 (delta 34), pack-reused 0
Receiving objects: 100% (108/108), 9.36 KiB | 736.00 KiB/s, done.
Resolving deltas: 100% (48/48), done.

会在当前目录下创建test-ui项目目录。

暂存区add

可以简单理解为,git add命令就是把要提交的所有修改放到暂存区(Stage),然后,执行git commit就可以一次性把暂存区的所有修改提交到仓库。

指令 描述
git add [file1] [file2] 添加文件到暂存区,包括修改的文件、新增的文件
git add [dir] 同上,添加目录到暂存区,包括子目录
git add . 同上,添加所有修改、新增文件(未跟踪)到暂存区
git rm [file] 删除工作区文件,并且将这次删除放入暂存区
1
2
3
4
5
6
7
8
9
10
11
# 添加指定文件到暂存区,包括被修改的文件
$ git add [file1] [file2] ...

# 添加当前目录的所有文件到暂存区
$ git add .

# 删除工作区文件,并且将这次删除放入暂存区
$ git rm [file1] [file2] ...

# 改名文件,并且将这个改名放入暂存区
$ git mv [file-original] [file-renamed]

提交commit-记录

git commit提交是以时间顺序排列被保存到数据库中的,就如游戏关卡一样,每一次提交(commit)就会产生一条记录:id + 描述 + 快照内容。

🔸commit id:根据修改的文件内容采用摘要算法(SHA1)计算出不重复的 40 位字符,这么长是因为 Git 是分布式的,要保证唯一性、完整性,一般本地指令中可以只用前几位(6)。即使多年以后,依然可通过id找到曾经的任何内容和变动,再也不用担心丢失了。
🔸描述:针对本次提交的描述说明,建议准确填写,就跟代码中的注释一样,很重要。
🔸快照:就是完整的版本文件,以对象树的结构存在仓库下\.git\objects目录里,这也是 Git 效率高的秘诀之一。

多个提交就形成了一条时间线,每次提交完,会移动当前分支master、HEAD的“指针”位置。

指令 描述
git commit -m '说明' 提交变更,参数-m设置提交的描述信息,应该正确提交,不带该参数会进入说明编辑模式
git commit -a 参数-a,表示直接从工作区提交到版本库,略过了git add步骤,不包括新增的文件
git commit [file] 提交暂存区的指定文件到仓库区
git commit --amend -m 使用一次新的commit,替代上一次提交,会修改commithash值(id
git log -n20 查看日志(最近20条),不带参数-n则显示所有日志
git log -n20 --oneline 参数--oneline可以让日志输出更简洁(一行)
git log -n20 --graph 参数--graph可视化显示分支关系
git log --follow [file] 显示某个文件的版本历史
git blame [file] 以列表形式显示指定文件的修改记录
git reflog 查看所有可用的历史版本记录(实际是HEAD变更记录),包含被回退的记录(重要)
git status 查看本地仓库状态,比较常用的指令,加参数-s简洁模式

通过git log指令可以查看提交记录日志,可以很方便的查看每次提交修改了哪些文件,改了哪些内容,从而进行恢复等操作。

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
# 提交暂存区到仓库区
$ git commit -m [message]
# 提交所有修改到仓库
$ git commit -a -m'修改README的版权信息'

# 提交暂存区的指定文件到仓库区
$ git commit [file1] [file2] ... -m [message]

# 使用一次新的commit,替代上一次提交
# 如果代码没有任何新变化,则用来改写上一次commit的提交信息
$ git commit --amend -m [message]

$ git log -n2
commit 412b56448568ff362ef312507e78797befcf2846 (HEAD -> main)
Author: Kanding <123anding@163.com>
Date: Thu Dec 1 19:02:22 2022 +0800

commit c0ef58e3738f7d54545d8c13d603cddeee328fcb
Author: Kanding <123anding@163.com>
Date: Thu Dec 1 16:52:56 2022 +0800

# 用参数“--oneline”可以让日志输出更简洁(一行)
$ git log -n2 --oneline
5444126 (HEAD -> main, origin/main, origin/HEAD) Update README.md
228362e Merge branch 'main' of github.com:xxx/test-ui

Git的“指针”引用

Git 中最重要的就是提交记录了,其他如标签、分支、HEAD都是对提交记录的“指针”引用,指向这些提交记录。

  • 提交记录之间也存在“指针”引用,每个提交会指向其上一个提交。
  • 标签就是对某一个提交记录的的 固定 “指针”引用,取一个别名更容易记忆一些关键节点。存储在工作区根目录下.git\refs\tags
  • 分支也是指向某一个提交记录的“指针”引用,“指针”位置可变,如提交、更新、回滚。存储在工作区根目录下.git\refs\heads
  • HEAD:指向当前活动分支(最新提交)的一个“指针”引用,存在在.git/HEAD文件中,存储的内容为ref: refs/heads/master

上图中:

  • HEAD始终指向当前活动分支,多个分支只能有一个处于活动状态。
  • 标签t1在某一个提交上创建后,就不会变了。而分支、HEAD的位置会改变。

打开这些文件内容看看,就更容易理解这些“指针”的真面目了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# tag
$ git tag -a 'v1' -m'v1版本'
$ cat .git/refs/tags/v1
a2e2c9caea35e176cf61e96ad9d5a929cfb82461

# main分支指向最新的提交
$ cat .git/refs/heads/main
8f4244550c2b6c23a543b741c362b13768442090

# HEAD指向当前活动分支
$ cat .git/HEAD
ref: refs/heads/main

# 切换到dev分支,HEAD指向了dev
$ git switch dev
Switched to branch 'dev'
$ cat .git/HEAD
ref: refs/heads/dev

这里的主分支名字为main,是因为该仓库是从 Github 上克隆的,Github 上创建的仓库默认主分支名字就是main,本地创建的仓库默认主分支名字为master

提交的唯一标识id,HEAD~n是什么意思

每一个提交都有一个唯一标识,主要就是提交的hashcommit id,在很多指令中会用到,如版本回退、拣选提交等,需要指定一个提交。那标识唯一提交有两种方式:

  • 首先就是commit id,一个40位编码,指令中使用的时候可以只输入前几位(6位)即可。
  • 还有一种就是HEAD~n,是基于当前HEAD位置的一个相对坐标。

HEAD表示当前分支的最新版本,是比较常用的参数。
HEAD^上一个版本,HEAD^^上上一个版本。
HEAD~HEAD~1表示上一个版本,以此类推,HEAD^10为最近第 10 个版本。
HEAD@{2}git reflog日志中标记的提交记录索引。

通过git log、git reflog可以查看历史日志,可以看每次提交的唯一编号(hash)。区别是git reflog可以查看所有操作的记录(实际是HEAD变更记录),包括被撤销回退的提交记录。

1
2
3
4
5
6
7
8
9
10
11
$ git reflog -n10
5acc914 (HEAD -> main) HEAD@{0}: reset: moving to HEAD~
738748b (dev) HEAD@{1}: reset: moving to HEAD~
9312c3e HEAD@{2}: reset: moving to HEAD~
db03fcb HEAD@{3}: reset: moving to HEAD~
1b81fb3 HEAD@{4}: reset: moving to HEAD~
41ea423 HEAD@{5}: reset: moving to HEAD~
d3e15f9 HEAD@{6}: reset: moving to d3e15f9
1b81fb3 HEAD@{7}: reset: moving to HEAD~1
41ea423 HEAD@{8}: reset: moving to HEAD~
d3e15f9 HEAD@{9}: reset: moving to HEAD~

比较diff

git diff用来比较不同文件版本之间的差异。

指令 描述
git diff 查看暂存区和工作区的差异
git diff [file] 同上,指定文件
git diff --cached 查看已暂存的改动,就是暂存区与新版本HEAD进行比较
git diff --staged 同上
git diff --cached [file] 同上,指定文件
git diff HEAD 查看已暂存的+未暂存的所有改动,就是与最新版本HEAD进行比较
git diff HEAD~ 同上,与上一个版本比较。HEAD~表示上一个版本,HEAD~10为最近第10个版本
git diff [id] [id] 查看两次提交之间的差异
git diff [branch] 查看工作区和分支直接的差异
1
2
3
4
5
6
7
8
# 查看文件的修改
$ git diff README.md

# 查看两次提交的差异
$ git diff 8f4244 1da22

# 显示今天你写了多少行代码:工作区+暂存区
$ git diff --shortstat "@{0 day ago}"

远程仓库

Git 作为分布式的版本管理系统,每个终端都有自己的 Git 仓库。但团队协作还需一个中间仓库,作为中心,同步各个仓库。于是服务端(远程)仓库就来承担这个职责,服务端不仅有仓库,还配套相关管理功能。

远程用户登录

Git 服务器一般提供两种登录验证方式:

  • HTTS:基于 HTTPS 连接,使用用户名、密码身份验证。
    每次都要输入用户名、密码,当然可以记住。
    地址形式:https://github.com/xxx/test-ui.git
  • SSL:采用SSL通信协议,基于公私钥进行身份验证,所以需要额外配置公私秘钥。
    不用每次输入用户名、密码,比较推荐的方法。
    地址形式:git@github.com:xxx/test-ui.git
1
2
3
4
5
6
7
#查看当前远程仓库使用的那种协议连接:
$ git remote -v
origin git@github.com:xxx/test-ui.git (fetch)
origin https://github.com/xxx/test-ui.git (push)

# 更改为https地址,即可切换连接模式。还需要禁用掉SSL, 才能正常使用https管理git
git config --global http.sslVerify false

远程用户登录:HTTS

基于HTTPS的地址连接远程仓库,Github 的共有仓库克隆、拉取是不需要验证的。

1
2
3
4
5
6
7
8
$ git clone 'https://github.com/xxx/test-ui.git'
Cloning into 'test-ui'...

# 仓库配置文件“.git/config”
[remote "origin"]
url = https://github.com/xxx/test-ui.git
fetch = +refs/heads/*:refs/remotes/origin/*
pushurl = https://github.com/xxx/test-ui.git

推送代码的时候就会提示输入用户名、密码了,否则无法提交。记住用户密码的方式有两种:

🔸URL地址配置:在原本URL地址上加上用户名、密码,https://后加用户名:密码@

1
2
3
4
5
# 直接修改仓库的配置文件“.git/config”
[remote "origin"]
url = https://用户名:密码@github.com/xxx/test-ui.git
fetch = +refs/heads/*:refs/remotes/origin/*
pushurl = https://github.com/xxx/test-ui.git

🔸本地缓存:会创建一个缓存文件.git-credentials,存储输入的用户名、密码。

1
2
3
4
5
6
# 参数“--global”全局有效,也可以针对仓库设置“--local
# store 表示永久存储,也可以设置临时存储
git config --global credential.helper store

# 存储内容如下,打开文件“仓库\.git\.git-credentials”
https://xxx:[加密内容]@github.com

远程用户登录:SSH

SSH(Secure Shell,安全外壳)是一种网络安全协议,通过加密和认证机制实现安全的访问和文件传输等业务,多用来进行远程登录、数据传输。SSH 通过公钥、私钥非对称加密数据,所以 SSH 需要生成一个公私钥对,公钥放服务器上,私有自己留着进行认证。

  1. 生成公私钥:通过Git指令ssh-keygen -t rsa生成公私钥,一路回车即可完成。生成在C:\Users\用户名\.ssh目录下,文件id_rsa.pub的内容就是公钥。
  2. 配置公钥:打开id_rsa.pub文件,复制内容。Github 上,打开Setting➤ SSH and GPG keys ➤ SSH keys ➤ 按钮New SSH key,标题(Title)随意,秘钥内容粘贴进去即可。

SSH 配置完后,可用ssh -T git@github.com来检测是否连接成功。

1
2
$ ssh -T git@github.com
Hi xxx! You've successfully authenticated, but GitHub does not provide shell access.

远程仓库指令

指令 描述
git clone [git地址] 从远程仓库克隆到本地(当前目录)
git remote -v 查看所有远程仓库,不带参数-v只显示名称
git remote show [remote] 显示某个远程仓库的信息
git remote add [name] [url] 增加一个新的远程仓库,并命名
git remote rename [old] [new] 修改远程仓库名称
git pull [remote] [branch] 取回远程仓库的变化,并与本地版本合并
git pull 同上,针对当前分支
git fetch [remote] 获取远程仓库的所有变动到本地仓库,不会自动合并!需要手动合并
git push 推送当前分支到远程仓库
git push [remote] [branch] 推送本地当前分支到远程仓库的指定分支
git push [remote] --force/-f 强行推送当前分支到远程仓库,即使有冲突,⚠️很危险!
git push [remote] --all 推送所有分支到远程仓库
git push –u 参数–u表示与远程分支建立关联,第一次执行的时候用,后面就不需要了
git remote rm [remote-name] 删除远程仓库
git pull --rebase 使用rebase的模式进行合并

推送push/拉取pull

git push、git pull是团队协作中最常用的指令,用于同步本地、服务端的更新,与他人协作。

🔸推送(push):推送本地仓库到远程仓库。
如果推送的更新与服务端存在冲突,则会被拒绝,push失败。一般是有其他人推送了代码,导致文件冲突,可以先pull代码,在本地进行合并,然后再push
🔸拉取(pull):从服务端(远程)仓库更新到本地仓库。

  • git pull:拉取服务端的最新提交到本地,并与本地合并,合并过程同分支的合并。
  • git fetch:拉取服务端的最新提交到本地,不会自动合并,也不会更新工作区。

fetch与pull有什么不同

两者都是从服务端获取更新,主要区别是fetch不会自动合并,不会影响当前工作区内容。

1
git pull = git fetch + git merge

如下面图中,git fetch只获取了更新,并未影响master、HEAD的位置。

要更新master、HEAD的位置需要手动执行git merge合并。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# fetch只更新版本库
$ git fetch
remote: Enumerating objects: 5, done.
remote: Counting objects: 100% (5/5), done.
remote: Compressing objects: 100% (3/3), done.
remote: Total 3 (delta 2), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (3/3), 663 bytes | 44.00 KiB/s, done.
From github.com:xxx/test-ui
2ba12ca..c64f5b5 main -> origin/main

# 执行合并,合并自己
$ git merge
Updating 2ba12ca..c64f5b5
Fast-forward
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)

Git 分支

分支是从主线分离出去的“副本”,分支就像是平行宇宙,可独立发展,独立编辑、提交,也可以和其他分支合并。分支是 Git 的核心必杀利器之一,分支创建、切换、删除都非常快,他非常的轻量。所以,早建分支!

分支Branch

比如有一个项目团队,准备10月份发布新版本,要新开发一堆黑科技功能,占领市场。你和小伙伴“小美”一起负责开发一个新功能A,开发周期2周,在这两周你们的代码不能影响其他人,不影响主分支。这个时候就可以为这个新功能创建一个分支,你们两在这个分支上干活,2周后代码开发完了、测试通过,就可以合并进要发版的开发分支了。安全、高效,不影响其他人工作,完美!

在实际项目中,一般会建几个主线分支。
🔸 master:作为主分支,存放稳定的代码,就是开发后测试通过的代码,不允许随便修改和合并。
🔸 开发分支:用于团队日常开发用,比如团队计划 10 月份开发 10 个功能并发版,则在此分支上进行,不影响主分支的稳定。
🔸 功能A分支:开发人员根据自己的需要,可以创建一些临时分支用于特定功能的开发,开发完毕后再合并到开发分支,并删除该分支。

分支就是指向某一个提交记录的“指针”引用,因此创建分支是非常快的,不管仓库多大。当我们运行git branch dev创建了一个名字为dev的分支,Git实际上是在.git\refs\heads下创建一个dev的引用文件(没有扩展名)。

1
2
3
$ git branch dev
$ cat .git/refs/heads/dev
ca88989e7c286fb4ba56785c2cd8727ea1a07b97

分支指令🔥

指令 描述
git branch 列出所有本地分支,加参数-v显示详细列表,下同
git branch -r 列出所有远程分支
git branch -a 列出所有本地分支和远程分支,用不同颜色区分
git branch [branch-name] 新建一个分支,但依然停留在当前分支
git branch -d dev 删除dev分支,-D(大写)强制删除
git checkout -b dev 从当前分支创建并切换到dev分支
git checkout -b feature1 dev 从本地dev分支代码创建一个 feature1分支,并切换到新分支
git branch [branch] [commit] 新建一个分支,指向指定commit id
git branch –track [branch] [remote-branch] 新建一个分支,与指定的远程分支建立关联
git checkout -b hotfix remote hotfix 从远端remote的hotfix分支创建本地hotfix分支
git branch –set-upstream [branch] [remote-branch] 在现有分支与指定的远程分支之间建立跟踪关联:git branch –set-upstream hotfix remote/hotfix
git checkout [branch-name] 切换到指定分支,并更新工作区
git checkout . 撤销工作区的(未暂存)修改,把暂存区恢复到工作区。
git checkout HEAD . 撤销工作区、暂存区的修改,用HEAD指向的当前分支最新版本替换
git merge [branch] 合并指定分支到当前分支
git merge –no-ff dev 合并dev分支到当前分支,参数–no-ff禁用快速合并模式
git push origin –delete [branch-name] 删除远程分支
git rebase master 将当前分支变基合并到master分支
✅switch:新的分支切换指令 切换功能和checkout一样,switch只单纯的用于切换
git switch master 切换到已有的master分支
git switch -c dev 创建并切换到新的dev分支

📢关于checkout指令:checkout是 Git 的底层指令,比较常用,也比较危险,他会重写工作区。支持的功能比较多,能撤销修改,能切换分支,这也导致了这个指令比较复杂。在Git 2.23版本以后,增加了git switch、git reset指令。
git switch:专门用来实现分支切换。
git reset:专门用来实现本地修改的撤销。

1
2
3
4
$ git branch
dev
* main
# 列出了当前的所有分支,星号“*”开头的“main”为当前活动分支。

分支的切换checkout

代码仓库可以有多个分支,master为默认的主分支,但只有一个分支在工作状态。所以要操作不同分支,需要切换到该分支,HEAD就是指向当前正在活动的分支。

1
2
3
4
5
6
# 切换到dev分支,HEAD指向了dev
# 此处 switch 作用同 checkout,switch只用于切换,不像checkout功能很多
$ git switch dev
Switched to branch 'dev'
$ cat .git/HEAD
ref: refs/heads/dev

使用git checkout dev切换分支时,干了两件事:

  1. HEAD指向dev:修改HEAD的“指针”引用,指向dev分支。
  2. 还原工作空间:把dev分支内容还原到工作空间。

此时的活动分支就是dev了,后续的提交就会更新到dev分支了。

❓切换时还没提交的代码怎么办?

  • 如果修改(包括未暂存、已暂存)和待切换的分支没有冲突,则切换成果,且未提交修改会一起带过去,所以要注意!
  • 如果有冲突,则会报错,提示先提交或隐藏。

合并merge&冲突

把两个分支的修改内容合并到一起,常用的合并指令git merge [branch],将分支[branch]合并到当前分支。根据要合并的内容的不同,具体合并过程就会有多种情况。

🔸快速合并(Fast forward)
如下图,master分支么有任何提交,git merge dev合并分支devmaster,此时合并速度就非常快,直接移动master的“指针”引用到dev即可。这就是快速合并,不会产生新的提交。

合并devmaster,注意要先切换到master分支,然后执行git merge dev,把dev合并到当前分支。

📢强制不用快速合并:git merge --no-ff -m "merge with no-ff" dev,参数--no-ff不启用快速合并,会产生一个新的合并提交记录。

🔸普通合并
如果master有变更,存在分支交叉,则会把两边的变更合并成一个提交。

  • 如果两边变更的文件不同,没有什么冲突,就自动合并了。
  • 如果有修改同一个文件,则会存在冲突,到底该采用哪边的,程序无法判断,就换产生冲突。冲突内容需要人工修改后再重新提交,才能完成最终的合并。

上图中,创建dev分支后,两个分支都有修改提交,因此两个分支就不在一条顺序线上了,此时合并devmaster就得把他们的修改进行合并操作了。

  • v5、v7共同祖先是v4,从这里开始分叉。
  • Git 会用两个分支的末端v6v8以及它们的共同祖先v4进行三方合并计算。合并之后会生成一个新(和并)提交v9
  • 合并提交v9就有两个祖先v6、v8

🔸处理冲突<<<<<<< HEAD
在有冲突的文件中,<<<<<<< HEAD开头的内容就表示是有冲突的部分,需要人工处理,可以借助一些第三方的对比工具。人工处理完毕后,完成合并提交,才最终完成此次合并。=======分割线上方是当前分支的内容,下方是被合并分支的变更内容。

变基rebase

把两个分支的修改内容合并到一起的办法有两种:mergerebase,作用都是一样的,区别是rebase的提交历史更简洁,干掉了分叉,merge的提交历史更完整。

  • dev上执行git rebase master变基,将dev分支上分叉的v7、v8生成补丁,然后在master分支上应用补丁,产生新的v7'、v8'新的提交。
  • 然后回到master分支,完成合并git merge dev,此时的合并就是快速合并了。
  • 最终的提交记录就没有分叉了。
1
2
3
$ git rebase master
$ git checkout master
$ git merge dev

标签管理

标签(Tags)指的是某个分支某个特定时间点的状态,是对某一个提交记录的的固定“指针”引用。一经创建,不可移动,存储在工作区根目录下.git\refs\tags。可以理解为某一次提交(编号)的别名,常用来标记版本。所以发布时,一般都会打一个版本标签,作为该版本的快照,指向对应提交commit

当项目达到一个关键节点,希望永远记住那个特别的提交快照,你可以使用git tag给它打上标签。比如我们今天终于完成了V1.1版本的开发、测试,并成功上线了,那就可给今天最后这个提交打一个标签“V1.1”,便于版本管理。

默认标签是打在最新提交的commit上的,如果希望在指定的提交上打标签则带上提交编号(commit id):git tag v0.9 f52c633

指令 描述
git tag 查看标签列表
git tag -l ‘a*’ 查看名称是“a”开头的标签列表,带查询参数
git show [tagname] 查看标签信息
git tag [tagname] 创建一个标签,默认标签是打在最新提交的commit上的
git tag [tagname] [commit id] 新建一个tag在指定commit上
git tag -a v5.1 -m’v5.1版本’ 创建标签v5.1.1039,-a指定标签名,-m指定说明文字
git tag -d [tagname] 删除本地标签
git checkout v5.1.1039 切换标签,同切换分支
git push [remote] v5.1 推送标签,标签不会默认随代码推送推送到服务端
git push [remote] –tags 提交所有tag

如果要推送某个标签到远程,使用命令git push origin [tagname],或者,一次性推送全部到远程:git push origin --tags

📢注意:标签总是和某个commit挂钩。如果这个commit既出现在master分支,又出现在dev分支,那么在这两个分支上都可以看到这个标签。

1
2
3
4
5
6
7
8
# tag
$ git tag -a 'v1' -m'v1版本'
$ cat .git/refs/tags/v1
a2e2c9caea35e176cf61e96ad9d5a929cfb82461

# 查看标签列表
$ git tag
v1

撤销变更

发现写错了要回退怎么办?

  • ❓还没提交的怎么撤销?checkout、reset还未提交的修改(工作区、暂存区)不想要了,用签出指令(checkout)进行撤销清除。或者用checkout的新版回滚指令reset
  • ❓已提交但么有push的提交如何撤销?—— reset、revert
  • ❓已push的提交如何撤销?—— 同上,先本地撤销,然后强制推送git push origin -f,⚠️注意慎用! 记得先pull获取更新。

后悔指令🔥

指令 描述
git checkout . 撤销工作区的(未暂存)修改,把暂存区恢复到工作区。不影响暂存区,如果没暂存,则撤销所有工作区修改
git checkout [file] 同上,file指定文件
git checkout HEAD . 撤销工作区、暂存区的修改,用HEAD指向的当前分支最新版本替换工作区、暂存区
git checkout HEAD [file] 同上,file指定文件
git reset 撤销暂存区状态,同git reset HEAD,不影响工作区
git reset HEAD [file] 同上,指定文件file,HEAD可省略
git reset [commit] 回退到指定版本,清空暂存区,不影响工作区。工作区需要手动git checkout签出
git reset –soft [commit] 移动分支master、HEAD到指定的版本,不影响暂存区、工作区,需手动git checkout签出更新
git reset –hard HEAD 撤销工作区、暂存区的修改,用当前最新版
git reset –hard HEAD~ 回退到上一个版本,并重置工作区、暂存区内容。
git reset –hard [commit] 回退到指定版本,并重置工作区、暂存区内容。
git revert[commit] 撤销一个提交,会用一个新的提交(原提交的逆向操作)来完成撤销操作,如果已push则重新push即可

git checkout .、git checkout [file]会清除工作区中未添加到暂存区的修改,用暂存区内容替换工作区。
git checkout HEAD .、git checkout HEAD [file]会清除工作区、暂存区的修改,用HEAD指向的当前分支最新版本替换暂存区、工作区。

1
2
3
4
5
6
7
# 只撤销工作区的修改(未暂存)
$ git checkout .
Updated 1 path from the index

# 撤销工作区、暂存区的修改
$ git checkout HEAD .
Updated 1 path from f951a96

回退版本reset

reset是专门用来撤销修改、回退版本的指令,支持的场景比较多,多种撤销姿势,所以参数组合也比较多。简单理解就是移动master分支、HEAD的“指针”地址。

回退版本git reset --hard v4git reset --hard HEAD~2,master、HEAD会指向v4提交,v5、v6就被废弃了。
也可以重新恢复到v6版本:git reset --hard v6,就是移动master、HEAD的“指针”地址。

reset有三种模式,对应三种参数:mixed(默认模式)、soft、hard。三种参数的主要区别就是对工作区、暂存区的操作不同。

  • mixed为默认模式,参数可以省略。
  • 只有hard模式会重置工作区、暂存区,一般用这个模式会多一点。
模式名称 描述 HEAD的位置 暂存区 工作区
soft 回退到某一个版本,工作区不变,需手动git checkout 修改 不修改 不修改
mixed(默认) 撤销暂存区状态,不影响工作区,需手动git checkout 修改 修改 不修改
hard 重置未提交修改(工作区、暂存区) 修改 修改 修改

穿梭前,用git log可以查看提交历史,以便确定要回退到哪个版本。要重返未来,用git reflog查看命令历史,以便确定要回到未来的哪个版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
git reset [--soft | --mixed | --hard] [HEAD]

# 撤销暂存区
$ git reset
Unstaged changes after reset:
M R.md

# 撤销工作区、暂存区修改
$ git reset --hard HEAD

# 回退版本库到上一个版本,并重置工作区、暂存
$ git reset --hard HEAD~

# 回到原来的版本(恢复上一步的撤销操作),并重置工作区、暂存
$ git reset --hard 5f8b961

# 查看所有历史提交记录
$ git reflog
ccb9937 (HEAD -> main, origin/main, origin/HEAD) HEAD@{0}: commit: 报表新增导入功能
8f61a60 HEAD@{1}: commit: bug:修复报表导出bug
4869ff7 HEAD@{2}: commit: 用户报表模块开发
4b1028c HEAD@{3}: commit: 财务报表模块开发完成

撤销提交revert

安全的撤销某一个提交记录,基本原理就是生产一个新的提交,用原提交的逆向操作来完成撤销操作。注意,这不同于reset,reset是回退版本,revert只是用于撤销某一次历史提交,操作是比较安全的。

如上图:

  • 想撤销v4的修改,执行git revert v4,会产生一个新的提交v-4,是v4的逆向操作。
  • 同时更新masterHEAD“指针”位置,以及工作区内容。
  • 如果已push则重新push即可。
1
2
3
4
# revert撤销指定的提交,“-m”附加说明
$ git revert 41ea42 -m'撤销对***的修改'
[main 967560f] Revert "123"
1 file changed, 1 deletion(-)

checkout/reset/revert总结

标题/指令 checkout reset revert
主要作用(撤销) 撤销工作区、暂存区未提交修改 回退版本,重置工作区、暂存区 撤销某一次提交
撤销工作区 git checkout [file] git reset HEAD [file]
撤销工作区、暂存区 git checkout HEAD [file] git reset –hard HEAD [file]
回退版本 git reset –hard [commit]
安全性 只针对未提交修改,安全 如回退了已push提交,不安全 安全

可看出reset完全可以替代checkout来执行撤销、回退操作,reset本来也是专门用来干这个事情的,可以抛弃checkout了(撤销的时候)。

工作中的Git实践

Git flow

Git flow(Git工作流程)是指软件项目中的一种 Git 分支管理模型,经过了大量的实践和优化,被认为是现代敏捷软件开发和 DevOps(开发、技术运营和质量保障三者的交集)的最佳实践。Git flow主要流程及关键分支:

✅主分支:master,稳定版本代码分支,对外可以随时编译发布的分支,不允许直接Push代码,只能请求合并(pull request),且只接受hotfix、release分支的代码合并。

✅热修复分支:hotfix,针对线上紧急问题、bug修复的代码分支,修复完后合并到主分支、开发分支。

  1. 切换到hotfix分支,从master更新代码;
  2. 修复bug
  3. 合并代码到dev分支,在本地 Git 中操作即可;
  4. 合并代码到master分支。

✅发版分支:release,版本发布分支,用于迭代版本发布。迭代开发完成后,合并dev代码到release,在release分支上编译发布版本,以及修改bug(定时同步bug修改到dev分支)。测试完成后此版本可以作为发版使用,然后把稳定的代码pushmaster分支,并打上版本标签。
✅开发分支:dev,开发版本分支,针对迭代任务开发的分支,日常开发原则上都在此分支上面,迭代完成后合并到release分支,开发、发版两不误。

✅其他开发分支:dev-xxx,开发人员可以针对模块自己创建本地分支,开发完成后合并到dev开发分支,然后删除本地分支。

stash

当你正在dev分支开发一个功能时,代码写了一半,突然有一个线上的bug急需要马上修改。dev分支Bug没写完,不方便提交,就不能切换到主分支去修复线上bug。Git 提供一个stash功能,可以把当前工作区、暂存区 未提交的内容“隐藏”起来,就像什么都没发生一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 有未提交修改,切换分支时报错
$ git checkout dev
error: Your local changes to the following files would be overwritten by checkout:
README.md
Please commit your changes or stash them before you switch branches.
Aborting

# 隐藏
$ git stash
Saved working directory and index state WIP on main: 2bc012c s

# 查看被隐藏的内容
$ git stash list
stash@{0}: WIP on main: 2bc012c s

# 比较一下,什么都没有,一切都没有发生过!
$ git diff

# 去其他分支修改bug,修复完成回到当前分支,恢复工作区
$ git stash pop

在上面示例中,有未提交修改,切换分支时报错。错误提示信息很明确了,commit提交或stash隐藏:Please commit your changes or stash them before you switch branches.

📢 如果切换分支时,未提交修改的内容没有冲突,是可以成功切换的,未提交修改会被带过去。

指令 描述
git stash 把未提交内容隐藏起来,包括未暂存、已暂存。等以后恢复现场后继续工作
git stash list 查看所有被隐藏的内容列表
git stash pop 恢复被隐藏的内容,同时删除隐藏记录
git stash save “message” 同git stash,可以备注说明message
git stash apply 恢复被隐藏的文件,但是隐藏记录不删除
git stash drop 删除隐藏记录

🪧当然这里先提交到本地也是可以的,只是提交不是一个完整的功能代码,而是残缺的一部分,影响也不大。

拣选提交cherry-pick

当有一个紧急bug,在dev上修复完,我们需要把dev上的这个bug修复所做的修改“复制”到master分支,但不想把整个dev合并过去。为了方便操作,Git 专门提供了一个cherry-pick命令,让我们能复制一个特定的提交到当前分支,而不管这个提交在哪个分支。

如上图,操作过程相当于将该提交导出为补丁文件,然后在当前HEAD上重放,形成无论内容还是提交说明都一致的提交。

  • 希望把dev分支上的v7提交的内容合并到master,但不需要其他的内容。
  • master分支上执行指令git cherry-pick v7,会产生一个新的v7'提交,内容和v7相同。
  • 同时更新master、HEAD,以及工作区。
1
2
# 选择一个commit,合并进当前分支
$ git cherry-pick [commit]

编写程序时遇到的错误可大致分为 2 类,分别为语法错误和运行时错误。

Python语法错误

语法错误,也就是解析代码时出现的错误。当代码不符合 Python 语法规则时,Python解释器在解析时就会报出SyntaxError语法错误,与此同时还会明确指出最早探测到错误的语句。

语法错误多是开发者疏忽导致的,属于真正意义上的错误,是解释器无法容忍的,因此,只有将程序中的所有语法错误全部纠正,程序才能执行。

Python运行时错误

运行时错误,即程序在语法上都是正确的,但在运行时发生了错误。

1
a = 1/0

上面这句代码的意思是“用 1 除以 0,并赋值给 a 。因为 0 作除数是没有意义的,所以运行后会产生如下错误:

1
2
3
4
5
>>> a = 1/0
Traceback (most recent call last):
File "<pyshell#2>", line 1, in <module>
a = 1/0
ZeroDivisionError: division by zero

以上运行输出结果中,前两段指明了错误的位置,最后一句表示出错的类型。在 Python 中,把这种运行时产生错误的情况叫做异常。这种异常情况还有很多,常见的几种异常情况:

异常类型 含义
AssertionError 当 assert 关键字后的条件为假时,程序运行会停止并抛出 AssertionError 异常
AttributeError 当试图访问的对象属性不存在时抛出的异常
IndexError 索引超出序列范围会引发此异常
KeyError 字典中查找一个不存在的关键字时引发此异常
NameError 尝试访问一个未声明的变量时,引发此异常
TypeError 不同类型数据之间的无效操作
ZeroDivisionError 除法运算中除数为 0 引发此异常

当一个程序发生异常时,代表该程序在执行时出现了非正常的情况,无法再执行下去。默认情况下,程序是要终止的。如果要避免程序退出,可以使用捕获异常的方式获取这个异常的名称,再通过其他的逻辑代码让程序继续运行,这种根据异常做出的逻辑处理叫作异常处理。

try except异常处理

Python 中,用try except语句块捕获并处理异常:

1
2
3
4
5
6
7
8
try:
可能产生异常的代码块
except [ (Error1, Error2, ... ) [as e] ]:
处理异常的代码块1
except [ (Error3, Error4, ... ) [as e] ]:
处理异常的代码块2
except [Exception]:
处理其它异常

该格式中,[] 括起来的部分可以使用,也可以省略。其中各部分的含义如下:

  • (Error1, Error2,...) 、(Error3, Error4,...):其中,Error1、Error2、Error3Error4都是具体的异常类型。显然,一个except块可以同时处理多种异常。
  • [as e]:作为可选参数,表示给异常类型起一个别名e,这样做的好处是方便在except块中调用异常类型。
  • [Exception]:作为可选参数,可以代指程序可能发生的所有异常情况,其通常用在最后一个except块。

try except的基本语法格式可以看出,try块有且仅有一个,但except代码块可以有多个,且每个except块都可以同时处理多种异常。
当程序发生不同的意外情况时,会对应特定的异常类型,Python 解释器会根据该异常类型选择对应的except块来处理该异常。

try except语句的执行流程如下:

  • 首先执行try中的代码块,如果执行过程中出现异常,系统会自动生成一个异常类型,并将该异常提交给 Python 解释器,此过程称为捕获异常。
  • 当 Python 解释器收到异常对象时,会寻找能处理该异常对象的except块,如果找到合适的except块,则把该异常对象交给该except块处理,这个过程被称为处理异常。如果 Python 解释器找不到处理异常的except块,则程序运行终止,Python 解释器也将退出。

事实上,不管程序代码块是否处于try块中,甚至包括except块中的代码,只要执行该代码块时出现了异常,系统都会自动生成对应类型的异常。但是,如果此段程序没有用try包裹,又或者没有为该异常配置处理它的except块,则 Python 解释器将无法处理,程序就会停止运行;反之,如果程序发生的异常经try捕获并由except处理完成,则程序可以继续执行。

1
2
3
4
5
6
7
8
9
10
try:
a = int(input("输入被除数:"))
b = int(input("输入除数:"))
c = a / b
print("您输入的两个数相除的结果是:", c )
except (ValueError, ArithmeticError):
print("程序发生了数字格式异常、算术异常之一")
except :
print("未知异常")
print("程序继续运行")

程序运行结果为:

1
2
3
输入被除数:a
程序发生了数字格式异常、算术异常之一
程序继续运行

上面程序中,第 6 行代码使用了(ValueError, ArithmeticError)来指定所捕获的异常类型,这就表明该except块可以同时捕获这 2 种类型的异常;第 8 行代码只有except关键字,并未指定具体要捕获的异常类型,这种省略异常类的except语句也是合法的,它表示可捕获所有类型的异常,一般会作为异常捕获的最后一个except块。

除此之外,由于try块中引发了异常,并被except块成功捕获,因此程序才可以继续执行,才有了“程序继续运行”的输出结果。

获取特定异常的有关信息

由于一个except可以同时处理多个异常,那么我们如何知道当前处理的到底是哪种异常呢?

其实,每种异常类型都提供了如下几个属性和方法,通过调用它们,就可以获取当前处理异常类型的相关信息:

  • args:返回异常的错误编号和描述字符串;
  • str(e):返回异常信息,但不包括异常信息的类型;
  • repr(e):返回较全的异常信息,包括异常信息的类型。
1
2
3
4
5
6
7
try:
1/0
except Exception as e:
# 访问异常的错误编号和详细信息
print(e.args)
print(str(e))
print(repr(e))

输出结果为:

1
2
3
('division by zero',)
division by zero
ZeroDivisionError('division by zero',)

从程序中可以看到,由于except可能接收多种异常,因此为了操作方便,可以直接给每一个进入到此except块的异常,起一个统一的别名 e。

try except else

在原本的try except结构的基础上,Python 异常处理机制还提供了一个else块,也就是原有try except语句的基础上再添加一个else块,即try except else结构。

使用else包裹的代码,只有当try块没有捕获到任何异常时,才会得到执行;反之,如果try块捕获到异常,即便调用对应的except处理完异常,else块中的代码也不会得到执行。

1
2
3
4
5
6
7
8
9
10
try:
result = 20 / int(input('请输入除数:'))
print(result)
except ValueError:
print('必须输入整数')
except ArithmeticError:
print('算术错误,除数不能为 0')
else:
print('没有出现异常')
print("继续执行")

可以看到,在原有try except的基础上,我们为其添加了else块。现在执行该程序:

1
2
3
4
请输入除数:4
5.0
没有出现异常
继续执行

如上所示,当我们输入正确的数据时,try块中的程序正常执行,Python 解释器执行完try块中的程序之后,会继续执行else块中的程序,继而执行后续的程序。

既然 Python 解释器按照顺序执行代码,那么else块有什么存在的必要呢?直接将else块中的代码编写在try except块的后面,不是一样吗?

当然不一样,现在再次执行上面的代码:

1
2
3
请输入除数:a
必须输入整数
继续执行

可以看到,当我们试图进行非法输入时,程序会发生异常并被try捕获,Python 解释器会调用相应的except块处理该异常。但是异常处理完毕之后,Python 解释器并没有接着执行 else块中的代码,而是跳过else,去执行后续的代码。

也就是说,else的功能,只有当try块捕获到异常时才能显现出来。在这种情况下,else块中的代码不会得到执行的机会。而如果我们直接把else块去掉,将其中的代码编写到try except的后面:

1
2
3
4
5
6
7
8
9
try:
result = 20 / int(input('请输入除数:'))
print(result)
except ValueError:
print('必须输入整数')
except ArithmeticError:
print('算术错误,除数不能为 0')
print('没有出现异常')
print("继续执行")

程序执行结果为:

1
2
3
4
请输入除数:a
必须输入整数
没有出现异常
继续执行

可以看到,如果不使用else块,try块捕获到异常并通过except成功处理,后续所有程序都会依次被执行。

try except finally:资源回收

Python 异常处理机制还提供了一个finally语句,通常用来为try块中的程序做扫尾清理工作。

注意,和else语句不同,finally只要求和try搭配使用,而至于该结构中是否包含except以及else,对于finally不是必须的(else必须和try except搭配使用)。

在整个异常处理机制中,finally语句的功能是:无论try块是否发生异常,最终都要进入finally语句,并执行其中的代码块。

基于finally语句的这种特性,在某些情况下,当try块中的程序打开了一些物理资源(文件、数据库连接等)时,由于这些资源必须手动回收,而回收工作通常就放在finally块中。

Python 垃圾回收机制,只能帮我们回收变量、类对象占用的内存,而无法自动完成类似关闭文件、数据库连接等这些的工作。

回收这些物理资源,必须使用finally块吗?当然不是,但使用finally块是比较好的选择。首先,try块不适合做资源回收工作,因为一旦try块中的某行代码发生异常,则其后续的代码将不会得到执行;其次exceptelse也不适合,它们都可能不会得到执行。而finally块中的代码,无论try块是否发生异常,该块中的代码都会被执行。

1
2
3
4
5
6
7
8
9
try:
a = int(input("请输入 a 的值:"))
print(20/a)
except:
print("发生异常!")
else:
print("执行 else 块中的代码")
finally :
print("执行 finally 块中的代码")

运行此程序:

1
2
3
4
请输入 a 的值:4
5.0
执行 else 块中的代码
执行 finally 块中的代码

可以看到,当try块中代码为发生异常时,except块不会执行,else块和finally块中的代码会被执行。

再次运行程序:

1
2
3
请输入 a 的值:a
发生异常!
执行 finally 块中的代码

可以看到,当try块中代码发生异常时,except块得到执行,而else块中的代码将不执行,finally块中的代码仍然会被执行。

finally块的强大还远不止此,即便当try块发生异常,且没有合适和except处理异常时,finally块中的代码也会得到执行。

1
2
3
4
5
try:
#发生异常
print(20/0)
finally :
print("执行 finally 块中的代码")

程序执行结果为:

1
2
3
4
5
执行 finally 块中的代码
Traceback (most recent call last):
File "D:\python3.6\1.py", line 3, in <module>
print(20/0)
ZeroDivisionError: division by zero

可以看到,当try块中代码发生异常,导致程序崩溃时,在崩溃前 Python 解释器也会执行finally块中的代码。

raise

Python 允许我们在程序中手动设置异常,使用raise语句即可。

1
raise [exceptionName [(reason)]]

其中,用[]括起来的为可选参数,其作用是指定抛出的异常名称,以及异常信息的相关描述。如果可选参数全部省略,则raise会把当前错误原样抛出;如果仅省略 (reason),则在抛出异常时,将不附带任何的异常描述信息。

也就是说,raise语句有如下三种常用的用法:

  • raise:单独一个raise。该语句引发当前上下文中捕获的异常(比如在except块中),或默认引发RuntimeError异常。
  • raise异常类名称:raise后带一个异常类名称,表示引发执行类型的异常。
  • raise异常类名称(描述信息):在引发指定类型的异常的同时,附带异常的描述信息。

显然,每次执行raise语句,都只能引发一次执行的异常。首先,我们来测试一下以上 3 种 raise 的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> raise
Traceback (most recent call last):
File "<pyshell#1>", line 1, in <module>
raise
RuntimeError: No active exception to reraise
>>> raise ZeroDivisionError
Traceback (most recent call last):
File "<pyshell#0>", line 1, in <module>
raise ZeroDivisionError
ZeroDivisionError
>>> raise ZeroDivisionError("除数不能为零")
Traceback (most recent call last):
File "<pyshell#2>", line 1, in <module>
raise ZeroDivisionError("除数不能为零")
ZeroDivisionError: 除数不能为零

当然,我们手动让程序引发异常,很多时候并不是为了让其崩溃。事实上,raise语句引发的异常通常用try except(else finally)异常处理结构来捕获并进行处理。

1
2
3
4
5
6
7
try:
a = input("输入一个数:")
#判断用户输入的是否为数字
if(not a.isdigit()):
raise ValueError("a 必须是数字")
except ValueError as e:
print("引发异常:",repr(e))

程序运行结果为:

1
2
输入一个数:a
引发异常: ValueError('a 必须是数字',)

可以看到,当用户输入的不是数字时,程序会进入 if 判断语句,并执行 raise 引发 ValueError 异常。但由于其位于 try 块中,因为 raise 抛出的异常会被 try 捕获,并由 except 块进行处理。

因此,虽然程序中使用了 raise 语句引发异常,但程序的执行是正常的,手动抛出的异常并不会导致程序崩溃。

raise 不需要参数

正如前面所看到的,在使用 raise 语句时可以不带参数:

1
2
3
4
5
6
7
try:
a = input("输入一个数:")
if(not a.isdigit()):
raise ValueError("a 必须是数字")
except ValueError as e:
print("引发异常:",repr(e))
raise

程序执行结果为:

1
2
3
4
5
6
输入一个数:a
引发异常: ValueError('a 必须是数字',)
Traceback (most recent call last):
File "D:\python3.6\1.py", line 4, in <module>
raise ValueError("a 必须是数字")
ValueError: a 必须是数字

这里重点关注位于except块中的raise,由于在其之前我们已经手动引发了ValueError异常,因此这里当再使用raise语句时,它会再次引发一次。

当在没有引发过异常的程序使用无参的raise语句时,它默认引发的是RuntimeError异常。

1
2
3
4
5
6
try:
a = input("输入一个数:")
if(not a.isdigit()):
raise
except RuntimeError as e:
print("引发异常:",repr(e))

程序执行结果为:

1
2
输入一个数:a
引发异常: RuntimeError('No active exception to reraise',)

sys.exc_info()方法:获取异常信息

在实际调试程序的过程中,有时只获得异常的类型是远远不够的,还需要借助更详细的异常信息才能解决问题。

捕获异常时,有 2 种方式可获得更多的异常信息,分别是:

  • 使用sys模块中的exc_info方法;
  • 使用traceback模块中的相关函数。

模块sys中,有两个方法可以返回异常的全部信息,分别是exc_info()last_traceback(),这两个函数有相同的功能和用法。

exc_info()方法会将当前的异常信息以元组的形式返回,该元组中包含 3 个元素,分别为type、valuetraceback,它们的含义分别是:

  • type:异常类型的名称,它是BaseException的子类
  • value:捕获到的异常实例。
  • traceback:是一个traceback对象。
1
2
3
4
5
6
7
8
#使用 sys 模块之前,需使用 import 引入
import sys
try:
x = int(input("请输入一个被除数:"))
print("30除以",x,"等于",30/x)
except:
print(sys.exc_info())
print("其他异常...")

当输入 0 时,程序运行结果为:

1
2
3
请输入一个被除数:0
(<class 'ZeroDivisionError'>, ZeroDivisionError('division by zero',), <traceback object at 0x000001FCF638DD48>)
其他异常...

输出结果中,第 2 行是抛出异常的全部信息,这是一个元组,有 3 个元素,第一个元素是一个ZeroDivisionError类;第 2 个元素是异常类型ZeroDivisionError类的一个实例;第 3 个元素为一个traceback对象。其中,通过前 2 个元素可以看出抛出的异常类型以及描述信息,对于第 3 个元素,是一个traceback对象,无法直接看出有关异常的信息,还需要对其做进一步处理。

要查看traceback对象包含的内容,需要先引进traceback模块,然后调用traceback模块中的print_tb方法,并将sys.exc_info()输出的traceback对象作为参数参入。

1
2
3
4
5
6
7
8
9
10
11
#使用 sys 模块之前,需使用 import 引入
import sys
#引入traceback模块
import traceback
try:
x = int(input("请输入一个被除数:"))
print("30除以",x,"等于",30/x)
except:
#print(sys.exc_info())
traceback.print_tb(sys.exc_info()[2])
print("其他异常...")

输入 0,程序运行结果为:

1
2
3
4
请输入一个被除数:0
File "C:\Users\mengma\Desktop\demo.py", line 7, in <module>
print("30除以",x,"等于",30/x)
其他异常...

可以看到,输出信息中包含了更多的异常信息,包括文件名、抛出异常的代码所在的行数、抛出异常的具体代码。

traceback模块:获取异常信息

除了使用sys.exc_info()方法获取更多的异常信息之外,还可以使用traceback模块,该模块可以用来查看异常的传播轨迹,追踪异常触发的源头。

1
2
3
4
5
6
7
8
9
10
11
class SelfException(Exception):
pass
def main():
firstMethod()
def firstMethod():
secondMethod()
def secondMethod():
thirdMethod()
def thirdMethod():
raise SelfException("自定义异常信息")
main()

运行上面程序,将会看到如下所示的结果:

1
2
3
4
5
6
7
8
9
10
11
12
Traceback (most recent call last):
File "C:\Users\mengma\Desktop\1.py", line 11, in <module>
main()
File "C:\Users\mengma\Desktop\1.py", line 4, in main <--mian函数
firstMethod()
File "C:\Users\mengma\Desktop\1.py", line 6, in firstMethod <--第三个
secondMethod()
File "C:\Users\mengma\Desktop\1.py", line 8, in secondMethod <--第二个
thirdMethod()
File "C:\Users\mengma\Desktop\1.py", line 10, in thirdMethod <--异常源头
raise SelfException("自定义异常信息")
SelfException: 自定义异常信息

从输出结果可以看出,异常从thirdMethod()函数开始触发,传到secondMethod()函数,再传到firstMethod()函数,最后传到main()函数,在main()函数止,这个过程就是整个异常的传播轨迹。

当应用程序运行时,经常会发生一系列函数或方法调用,从而形成“函数调用栈”。异常的传播则相反,只要异常没有被完全捕获(包括异常没有被捕获,或者异常被处理后重新引发了新异常),异常就从发生异常的函数或方法逐渐向外传播,首先传给该函数或方法的调用者,该函数或方法的调用者再传给其调用者,直至最后传到 Python 解释器,此时 Python 解释器会中止该程序,并打印异常的传播轨迹信息。

其实,上面程序的运算结果显示的异常传播轨迹信息非常清晰,它记录了应用程序中执行停止的各个点。最后一行信息详细显示了异常的类型和异常的详细消息。从这一行向上,逐个记录了异常发生源头、异常依次传播所经过的轨迹,并标明异常发生在哪个文件、哪一行、哪个函数处。

使用traceback模块查看异常传播轨迹,首先需要将traceback模块引入,该模块提供了如下两个常用方法:

  • traceback.print_exc():将异常传播轨迹信息输出到控制台或指定文件中。
  • format_exc():将异常传播轨迹信息转换成字符串。

从上面方法看不出它们到底处理哪个异常的传播轨迹信息。实际上我们常用的print_exc()print_exc([limit[, file]])省略了limit、file两个参数的形式。而print_exc([limit[, file]])的完整形式是print_exception(etype, value, tb[,limit[, file]]),在完整形式中,前面三个参数用于分别指定异常的如下信息:

  • etype:指定异常类型;
  • value:指定异常值;
  • tb:指定异常的traceback信息;

当程序处于except块中时,该except块所捕获的异常信息可通过sys对象来获取,其中sys.exc_type、sys.exc_value、sys.exc_traceback就代表当前except块内的异常类型、异常值和异常传播轨迹。

简单来说,print_exc([limit[, file]])相当于如下形式:

1
print_exception(sys.exc_etype, sys.exc_value, sys.exc_tb[, limit[, file]])

也就是说,使用print_exc([limit[, file]])会自动处理当前except块所捕获的异常。该方法还涉及两个参数:

  • limit:用于限制显示异常传播的层数,比如函数A调用函数B,函数B发生了异常,如果指定limit=1,则只显示函数A里面发生的异常。如果不设置limit参数,则默认全部显示。
  • file:指定将异常传播轨迹信息输出到指定文件中。如果不指定该参数,则默认输出到控制台。

借助于traceback模块的帮助,我们可以使用except块捕获异常,并在其中打印异常传播信息,包括把它输出到文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 导入trackback模块
import traceback
class SelfException(Exception): pass
def main():
firstMethod()
def firstMethod():
secondMethod()
def secondMethod():
thirdMethod()
def thirdMethod():
raise SelfException("自定义异常信息")
try:
main()
except:
# 捕捉异常,并将异常传播信息输出控制台
traceback.print_exc()
# 捕捉异常,并将异常传播信息输出指定文件中
traceback.print_exc(file=open('log.txt', 'a'))
  • Copyrights © 2017-2023 WSQ
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信