SDN(软件定义网络)初体验----Mininet

https://zhuanlan.zhihu.com/p/30935141

还记得我2013年考下CCIE RS后,在国外一个技术论坛偶然读到了一篇介绍SDN的文章,作者把SDN写得神乎其神,中心思想就是:完全靠网络工程师手动配置和手动排错,效率低下的传统网络迟早有”寿终正寝“的一天,而取而代之的就是能够带来”革命性改变“的SDN。的确,IT技术日新月异,当年CCIE RS v1 v2考试大纲里的那些古董级别的Apple Talk, FDDI, Token Ring, X.25, ATM等等,现在还有几个人有兴趣去花时间理解它们?由此自己开始关注Software Defined Network (软件定义网络)。

本篇文章是我2014年自学Mininet时的一些心得和笔记,温故知新,如今回味起来依然能学到不少东西:


1. SDN和传统网络最大的区别在于:SDN具有灵活的软件编程能力,让网络的自动化管理和控制能力获得空前的提升,能够有效地解决当前网络系统所面临的资源规模扩展受限、组网灵活性差的问题。

2. 传统网络设备的Control Plane和Data Plane在SDN中被完全拆开,互不干涉。

3. SDN的转发机制不再是Destination-based,而是Flow-based。

4. SDN的Control logic由SDN Controller(类似于现在的IOS之类的命令行操作系统,但运行方式不同)掌控。

5. Openflow, Opendaylight, OpenContrail等都是SDN的一部分。

6. 。。。。。。。。。。。

理论太多,不想赘述,因为学再多理论也比不过亲自动手尝试,还好最近找到了一款叫做Mininet的东西,这对所有SDN的初学者是一个福音。作为一个轻量级网络研究平台,Mininet已经出现很多年了,有志于研究SDN(openflow)的都知道它的来历和用途,关于Mininet的背景就不多做介绍了。下面是自己使用Mininet时做的一些笔记:

安装Mininet的步骤:

1. 下载Mininet(版本2.1.0)的镜像文件 (https://github.com/mininet/mininet/wiki/Mininet-VM-Images),这是一个基于Ubuntu的虚拟机文件。

2. 用VMware或者Virtual Box打开

Mininet使用笔记:

1. 学习Mininet之前,最好将Mininet官方的Walkthrough过一遍(http://mininet.org/walkthrough/

2. sudo mn命令将创建一个最简单的拓扑,包括一个SDN Controller (c0),一个交换机 (s1),两台主机 (h1和h2)

3. Mininet几个比较重要的选项和参数总结如下:

--topo= 这个是Mininet创建的虚拟网络的拓扑,有4种类型:

minimal – 即上面提到的sudo mn命令,包括一个SDN Controller (c0),一个交换机 (s1),两台主机 (h1和h2)

single,X – 一个交换机,下面直连X个主机(自已定义)

linear,X – 创建X个环状链路的交换机,每个交换机下面直连一个主机

tree,X – 树状型拓扑,有X个fanout

--switch= 创建不同类型的交换机

ovsk – Mininet默认自带的Open vSwitch,已经预装在VM里面

user – 比ovsk慢很多,不推荐使用

--controller= 即SDN Controller,三个参数

ovsc – Mininet默认自带的OVS Controller,已经预装在VM里面

nox – 顾名思义,NOX controller

remote – 不创建Controller,尝试连接外部Controller

--mac 创建自定义的MAC地址

来做个实验具体说明,首先创建一个交换机,3个主机,无Controller的拓扑。

命令: Sudo mn –topo=single,3 –mac –controller=remote

在SDN中,交换机是没有Control Plane的,也就是说它仅是一个纯粹的转发设备, 并且这种”无脑型“的Openflow交换机只有在收到SDN controller的指示后,才能做出转发决定。遇到未知traffic时,Openflow交换机只会做一件事:就是把它们转发给SDN controller,自己什么也不管。这大大降低了习惯在传统网络的交换机中做各种2层排错的网工们的工作量。

既然Controller是SDN网络的大脑,那么创建一个没有controller的SDN拓扑还能玩吗?当然可以,这里要用到dptcl这个工具,dptcl的作用是可以跳过controller,直接通过TCP 6634这个端口来控制和查看openflow交换机的flow table(记住SDN网络的转发机制是flow-based,不是destination-based),不过dptcl和SDN controller是完全不同的两种东西,不能划等号,这点切记。

DPTCL的命令格式:

dptcl [show/dump-flows/add-flow] tcp:127.0.0.1:6634

最终实验拓扑如下:

具体配置和验证命令:

1. 开启Wireshark,让它在后台运行

mininet@mininet-vm:~$ sudo wireshark

2. 创建一个交换机(ovsk类型),3个主机,无Controller的SDN网络

mininet@mininet-vm:~$ sudo mn --topo=single,3 --mac --switch=ovsk --controller=remote
*** Creating network
*** Adding controller
Unable to contact the remote controller at 127.0.0.1:6633 //无controller的拓扑
*** Adding hosts:
h1 h2 h3
*** Adding switches:
s1
*** Adding links:
(h1, s1) (h2, s1) (h3, s1)
*** Configuring hosts
h1 h2 h3
*** Starting controller
*** Starting 1 switches
s1*** Starting CLI:
mininet>

3. 查看网络节点

mininet> nodes
available nodes are:
c0 h1 h2 h3 s1
mininet>

4. 查看物理拓扑

mininet> net
h1 h1-eth0:s1-eth1
h2 h2-eth0:s1-eth2
h3 h3-eth0:s1-eth3
s1 lo: s1-eth1:h1-eth0 s1-eth2:h2-eth0 s1-eth3:h3-eth0
c0
mininet>

5. 查看各个节点的信息

mininet> dump
<Host h1: h1-eth0:10.0.0.1 pid=9730>
<Host h2: h2-eth0:10.0.0.2 pid=9731>
<Host h3: h3-eth0:10.0.0.3 pid=9732>
<OVSSwitch s1: lo:127.0.0.1,s1-eth1:None,s1-eth2:None,s1-eth3:None pid=9735>
<RemoteController c0: 127.0.0.1:6633 pid=9723>
mininet>

验证SDN交换机工作原理

Scenario #1 (SDN交换机flow table为空)

1. 首先在三个主机(h1,h2,h3)上开启Xterm (Windows用户需要安装Xming,并在putty里开启X-forwarding)

mininet> xterm h1 h2 h3

2. 用dpctl查看交换机当前的flow table信息 (由于还没有手动添加flow entry,该flow table为空)

mininet> dpctl dump-flows
*** s1 --------------------------------------------
NXST_FLOW reply (xid=0x4):

3. 在h1上ping h2,在h2上用tcpdump抓包,查看结果

结论: Ping失败,h2上没有收到任何ICMP echo request packet.

原因: 拓扑里没有SDN controller,我们也没有用dptcl给openflow交换机添加任何flow entry, 所以交换机不会做转发决定,并直接丢弃h1到h2的ping包。

Scenario #2 (为SDN交换机添加flow entry)

1. 用dpctl给SDN交换机添加双向的flow entry, 因为ping包除了echo request还有echo reply

mininet@mininet-vm:~$  dpctl add-flow tcp:127.0.0.1:6634 in_port=1,actions=output:2

mininet@mininet-vm:~$  dpctl add-flow tcp:127.0.0.1:6634 in_port=2,actions=output:1

2. 查看SDN交换机的flow table,两条flow entry添加成功。

mininet> dpctl dump-flows
*** s1 --------------------------------------------
NXST_FLOW reply (xid=0x4):
cookie=0x0, duration=27,213s, table=0, n_packets=5, n_bytes=378, idle_timeout=60, idle_age=7, in_port=1 actions=output:2
cookie=0x0, duration=27,213s, table=0, n_packets=5, n_bytes=378, idle_timeout=60, idle_age=7, in_port=2 actions=output:1

3. 在h1上ping h2,在h2和h3上用tcpdump抓包,查看结果

结论:h1成功ping到h2,并且h3没收到任何ping包。


其他一些常用dpctl命令及功能 (拓扑同上)

1. 关闭或开启openflow交换机的端口(等于对一个端口shutdown / no shutdown)

dpctl mod-port [port num] up/down  

2. 关闭交换机的端口1(下接h1),

mininet> dpctl mod-port 1 down   

关闭端口1后再来h1 ping h2,结果当然fail

*** s1 ------------------------------------------------------------------------
mininet> h1 ping h2
PING 10.0.0.2 (10.0.0.2) 56(84) bytes of data.
--- 10.0.0.2 ping statistics ---
3 packets transmitted, 0 received, 100% packet loss, time 2000ms

3. 开启交换机的端口1

mininet> dpctl mod-port 1 up 

ping成功

*** s1 ------------------------------------------------------------------------
mininet> h1 ping -c 2 h2
PING 10.0.0.2 (10.0.0.2) 56(84) bytes of data.
64 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=8.37 ms
64 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=1.02 ms
--- 10.0.0.2 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1002ms
rtt min/avg/max/mdev = 1.026/4.698/8.370/3.672 ms

4. 查看端口的统计信息,包括Tx,Rx counters, bytes以及Error counters等等

mininet> dpctl dump-ports
*** s1 ------------------------------------------------------------------------
OFPST_PORT reply (xid=0x2): 4 ports
port  3: rx pkts=7, bytes=558, drop=0, errs=0, frame=0, over=0, crc=0
      tx pkts=0, bytes=0, drop=0, errs=0, coll=0
port  1: rx pkts=32, bytes=2076, drop=0, errs=0, frame=0, over=0, crc=0
      tx pkts=14, bytes=1092, drop=0, errs=0, coll=0
port  2: rx pkts=33, bytes=2154, drop=0, errs=0, frame=0, over=0, crc=0
      tx pkts=26, bytes=1596, drop=0, errs=0, coll=0
port LOCAL: rx pkts=0, bytes=0, drop=0, errs=0, frame=0, over=0, crc=0
      tx pkts=0, bytes=0, drop=0, errs=0, coll=0

5. 查看端口的一层和二层信息

mininet> dpctl show
*** s1 ------------------------------------------------------------------------
OFPT_FEATURES_REPLY (xid=0x2): dpid:0000000000000001
n_tables:254, n_buffers:256
capabilities: FLOW_STATS TABLE_STATS PORT_STATS QUEUE_STATS ARP_MATCH_IP
actions:
 OUTPUT SET_VLAN_VID SET_VLAN_PCP STRIP_VLAN SET_DL_SRC SET_DL_DST 
SET_NW_SRC SET_NW_DST SET_NW_TOS SET_TP_SRC SET_TP_DST ENQUEUE
1(s1-eth1): addr:66:9a:a3:e0:64:8f
config:     0
state:      0
current:    10GB-FD COPPER
speed: 10000 Mbps now, 0 Mbps max
2(s1-eth2): addr:26:bb:36:e0:99:4e
config:     0
state:      0
current:    10GB-FD COPPER
speed: 10000 Mbps now, 0 Mbps max
3(s1-eth3): addr:76:c9:ca:0e:92:4b
config:     0
state:      0
current:    10GB-FD COPPER
speed: 10000 Mbps now, 0 Mbps max
LOCAL(s1): addr:fe:3f:bf:f6:26:42
config:     0
state:      0
speed: 0 Mbps now, 0 Mbps max
OFPT_GET_CONFIG_REPLY (xid=0x4): frags=normal miss_send_len=0

开篇时曾提到:默认情况下,SDN交换机的flow table为空,在没有controller的情况下,可以使用dpctl来查询和管理交换机的flow table,之前的实验里我用dpctl给交换机加了两个flow,让h1可以ping通h2。两条命令如下:

dpctl add-flow in_port=1,actions=output:2
dpctl add-flow in_port=2,actions=output:1

第一条命令的意思是:用dpctl对交换机添加flow,让交换机从s1-eth1这个端口接收到的所有traffic都从s1-eth2这个端口发出去。

第二条命令的意思是:用dpctl对交换机添加flow,让交换机从s1-eth2这个端口接收到的所有traffic都从s1-eth1这个端口发出去。(这里重点强调“所有traffic",原因后面解释)

添加这两条flow后,h1能够ping通h2.

mininet> h1 ping h2
PING 10.0.0.2 (10.0.0.2) 56(84) bytes of data.
64 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=3.80 ms
64 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=0.915 ms
64 bytes from 10.0.0.2: icmp_seq=3 ttl=64 time=1.50 ms
^C
--- 10.0.0.2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2005ms
rtt min/avg/max/mdev = 0.915/2.073/3.805/1.248 ms

除了这种匹配所有traffic的方法外,dpctl还允许自定义更详细的traffic类型,比如ARP,IPv4, IPv6, MPLS等等,用dpctl的命令来匹配这些traffic不难,关键是要弄懂交换机是怎样识别它收到的traffci是属于哪种类型的,从而参照自己的flowtable然后对该traffic进行转发。要弄懂这点,就必须了解EtherType(以太网类型字段)这个东西,首先来回顾一下二层帧(frame)的结构:

如图,我已经把EtherType标记出来了,它就在Source MAC字段的后面。

根据IEEE 802.3定义,EtherType字段长度为2Byte,它的作用是用来指明应用于Payload这个字段里的是什么协议,它的起始值是0x0800,指代的是IPv4这个协议,常见的EtherType数值和所对应的协议如下:

0x0800 = IPv4

0x0806 = ARP

0x86DD = IPv6

0x8847 = MPLS (unicast)

0x8848 = MPLS (multicast)

在dpctl里面,我们使用dl_type来指代EtherType,下面来做个试验,具体说明一下怎么在dpctl里面根据EtherType来自定义traffic的协议类型。

首先把之前添加的两条匹配所有traffic的flow拿掉,这里用dpctl del-flows这个命令,删除后用dpctl dump-flows验证,确保交换机flow table为空。

mininet> dpctl del-flows
*** s1 ------------------------------------------------------------------------
mininet> dpctl dump-flows
*** s1 ------------------------------------------------------------------------
NXST_FLOW reply (xid=0x4):

然后用dpctl给交换机添加两条traffic类型为IPv4的flow,命令如下:

dpctl add-flow dl_type=0x0800,nw_dst=10.0.0.2,actions=output:2
dpctl add-flow dl_type=0x0800,nw_dst=10.0.0.1,actions=output:1

第一条命令的意思是:用dpctl对交换机添加flow,让交换机把所有EtherType为0x0800(IPv4)并且destiation IP为10.0.0.2的traffic从s1-eth2这个端口发出去。

第二条命令的意思是:用dpctl对交换机添加flow,让交换机把所有EtherType为0x0800(IPv4)并且destiation IP为10.0.0.1的traffic从s1-eth1这个端口发出去。

添加完后验证一下交换机的flow table:

mininet> dpctl dump-flows
*** s1 ------------------------------------------------------------------------
NXST_FLOW reply (xid=0x4):
cookie=0x0, duration=477.255s, table=0, n_packets=0, n_bytes=0, idle_age=477, ip,nw_dst=10.0.0.2 actions=output:2
cookie=0x0, duration=469.45s, table=0, n_packets=0, n_bytes=0, idle_age=469, ip,nw_dst=10.0.0.1 actions=output:1

然后再次尝试h1是否能ping通h2

mininet> h1 ping -c 3 h2
PING 10.0.0.2 (10.0.0.2) 56(84) bytes of data.
--- 10.0.0.2 ping statistics ---
3 packets transmitted, 0 received, 100% packet loss, time 2013ms

结论:Ping失败!

原因:众所周知,处在同一网段下的host,它们之间的交流是L2 forwarding,是要靠ARP来解析MAC地址的,之前我们只匹配了0x0800 (IPv4) 这个协议,并没有匹配到0x0806(ARP),这样当交换机收到h1的ARP包后,因为没有controller,flow table里面也没有相应的flow告诉它如何转发这个ARP包,交换机只能将它丢弃,从而导致h1 ping h2失败。

添加ARP的dpctl命令如下:

dpctl add-flow dl_type=0x0806,actions=NORMAL

这条命令的意思是:用dpctl对交换机添加flow,让交换机以NORMAL形式(即广播)将所有ARP包从各个端口广播出去。

老规矩,添加完flow后,马上验证flow table

mininet> dpctl dump-flows

*** s1 ------------------------------------------------------------------------
NXST_FLOW reply (xid=0x4):
cookie=0x0, duration=1288.291s, table=0, n_packets=22, n_bytes=2156, idle_age=703, ip,nw_dst=10.0.0.2 actions=output:2
cookie=0x0, duration=1280.486s, table=0, n_packets=8, n_bytes=784, idle_age=727, ip,nw_dst=10.0.0.1 actions=output:1
cookie=0x0, duration=8.772s, table=0, n_packets=0, n_bytes=0, idle_age=8, arp actions=NORMAL

再次尝试h1是否能ping通h2

mininet> h1 ping -c 3 h2
PING 10.0.0.2 (10.0.0.2) 56(84) bytes of data.
64 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=6.34 ms
64 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=0.991 ms
64 bytes from 10.0.0.2: icmp_seq=3 ttl=64 time=1.08 ms
--- 10.0.0.2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2006ms
rtt min/avg/max/mdev = 0.991/2.807/6.345/2.502 ms

结论:IP和ARP的flow都添加完毕后,h1 ping h2成功!


用WIRESHARK抓包,靠实战理解openflow交换机和Controller之间的工作原理。

1. 首先创建一个最简单的拓扑,1个Controller, 1个交换机,2台HOST

mininet@mininet-vm:~$ sudo mn
*** Creating network
*** Adding controller
*** Adding hosts:
h1 h2
*** Adding switches:
s1
*** Adding links:
(h1, s1) (h2, s1)
*** Configuring hosts
h1 h2
*** Starting controller
*** Starting 1 switches
s1
*** Starting CLI:
mininet>

2. 重开一个putty窗口(记住enable X forwarding),ssh进入VM后开启wireshark

mininet@mininet-vm:~$ sudo wireshark &

3. Wireshark启动后,Interface List选lo0 (127.0.0.1),然后点Start,如下图

4.回到第一个putty窗口 (mininet>)下,用pingall命令来让h1(10.0.0.1)和h2(10.0.0.2)互相ping对方,因为这次的实验拓扑已经有了一台controller,所以无需再用dpctl来手动对交换机添加flow,h1已经可以直接ping通h2了。

mininet> pingall
*** Ping: testing ping reachability
h1 -> h2
h2 -> h1
*** Results: 0% dropped (2/2 received)

5. 回到Wireshark,这个时候应该capture到了很多TCP包,先不管它们,在Filter里输入of进行过滤,目的是为了抓到OFP (即Openflow Protocol)的包,如下图:

6. 点开第一个包,即Echo Request (SM)(8B)这个包,如下图:

从这个包里可以看出的信息是:

a.这个Echo Request是openflow交换机(127.0.0.1:60497) 发给Controller (127.0.0.1:6633)的,之间已经提到过,dpctl是靠TCP 6634这个端口来控制交换机的,而Controller则是用TCP 6633这个端口来控制交换机,而为了监控交换机和Controller之间的connectivity,交换机会不间断地向Controller发出Echo Request (注意这个不是ICMP的Echo Request),Controller收到Echo Request包后会向交换机返回一个Echo Reply包。

b. 点开最下面的Openflow Protocol(就不截图了),可以看到OFP的一些基本信息,比如version为0x01,Type为Echo Request (SM) (2),Length为8等等等。

7. 看完交换机和Echo Request和Echo Reply包后,接下来来看刚才用pingall命令,让h1 (10.0.0.1)和(10.0.0.2)互ping后出现的包。

如上图,在我抓的包里面:

a. 把序号为436,437,439,440的包分为一组,这一组是10.0.0.1 ping 10.0.0.2的Ping Echo Request以及Ping Echo Reply的包。

b. 把序号为441,442,443,444的包分为一组,这一组是10.0.0.2 ping 10.0.0.1的Ping Echo Request以及Ping Echo Reply的包。

8. 点开436这个包,如下图:

这里你会感到奇怪,为什么刚才在第7步里看到的该包,Source为10.0.0.1,Destination为10.0.0.2,为什么点开该包后,里面的内容却是Source127.0.0.1:60497(交换机), Destination 127.0.0.1:6633(Controller)?要回答这个问题,就需要回到第7步去看436这个包的Protocol,你会发现它已不再是我们在传统网络里理解的那个单独的ICMP包了,在SDN里,它已经变成了OFP+ICMP包。对这个OFP+ICMP包应该这样理解:当10.0.0.1 ping 10.0.0.2的时候,“无脑”的交换机(127.0.0.1:60497) 因为不知道怎么转发这个ICMP包,它唯一能做的就是用OFP包将这个ICMP包封装,将它转发给Controller (127.0.0.1:6633) 做决定,而这个由交换机重新封装后的ICMP包就叫做OFP+ICMP包。

9. 点开437这个包, 如下图:

这个包是接436这个包的,它是由Controller收到交换机发给它的OFP+ICMP后,Controller再返回给一个OFP包给交换机,所以它的Source127.0.0.1:6634,Destination127.0.0.1:60497。这里需要重点关注的是该包OpenFlow Protocol里面的内容。如图,依次点开OpenFlow Protocol>Output Actions(s)>Action,这里可以看到Output port:2 ,顾名思义,它的意思就是说:Controller现在告诉交换机,“关于这个10.0.0.1 ping 10.0.0.2的ICMP包,你把它从s1-eth2这个端口发出去”,这样h1 (10.0.0.1)的Ping echo request包就能到达h2 (10.0.0.2)了,因为h2就是直连在s1-eth2这个端口下的。

10. 后面的438-444就无需多解释了,有网络基础的都懂,它们和436还有437这两个包其实都是一个原理,只是source和destination不同。


要把SDN学精,学会写代码是必不可少的。整个Mininet的架构基本是用Python 2.0写出来的(注意不是3.0,前后两者差别很大),而自己的Python水平大概还停留在入门阶段。任重道远,下面是自己总结的Mininet中常见的一些Class, Methods, Functions还有Variables:

Topo: 用来创建拓扑,Mininet API中最基本的Class

addSwitch(): 在拓扑中创建一个switch,并返回switch name。

addHost(): 在拓扑中创建一个host,并返回host name。

addLink(): 在拓扑中创建一个双向的link,并返回link key,默认情况下link都是双向的(bidirectional)。

Mininet: 用来创建和管理一个拓扑的Main Class。

start(): 启动网络

pingAll():所有host相互ping对方,用来测试网络连接性

stop(): 关闭网络

net.hosts: 表示拓扑内所有的host

dumpNodeConnections(): 显示指定节点(Node)的connection

setLogLevel( 'info' | 'debug' | 'output' ): 设定Mininet默认的ouput level,一般用info。

举个简单的例子,用python写一个单交换机,下接N个host的拓扑的代码:

from mininet.topo import Topo
from mininet.net import Mininet
from mininet.util import dumpNodeConnections
from mininet.log import setLogLevel

class SingleSwitchTopo(Topo):
    def __init__(self, n=2, **opts):
        Topo.__init__(self, **opts) # 初始化拓扑以及默认的option
        switch = self.addSwitch('s1') # 添加一个名为s1的交换机
        for h in range(n):
            host = self.addHost('h%s' % (h + 1)) #添加主机
            self.addLink(host, switch) #添加双向连接

    def simpleTest():
        topo = SingleSwitchTopo(n=4)
        net = Mininet(topo) #用Main Class来创建拓扑
        net.start() #启动网络
        print "Dumping host connections"
        dumpNodeConnections(net.hosts) #显示拓扑内所有节点(host)的connection信息
        print "Testing network connectivity"
        net.pingAll() #所有host相互ping对方,用来测试网络连接性
        net.stop()

if __name__ == '__main__':
    setLogLevel('info') # 设置 Mininet 默认输出级别为info
    simpleTest()

验证:

# python test-single.py
*** Creating network
*** Adding controller
*** Adding hosts:h1 h2 h3 h4
*** Adding switches:
s1
*** Adding links:
(h1, s1) (h2, s1) (h3, s1) (h4, s1)
*** Configuring hosts
h1 h2 h3 h4
*** Starting controller
*** Starting 1 switches
s1
Dumping host connections
h1 h1-eth0:s1-eth1
h2 h2-eth0:s1-eth2
h3 h3-eth0:s1-eth3
h4 h4-eth0:s1-eth4
Testing network connectivity
*** Ping: testing ping reachability
h1 -> h2 h3 h4
h2 -> h1 h3 h4
h3 -> h1 h2 h4
h4 -> h1 h2 h3
*** Results: 0% dropped (12/12 received)

  • 2
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值