SDN(軟體定義網路)初體驗----Mininet
還記得我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 controllerUnable 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 hostsh1 h2 h3*** Starting controller*** Starting 1 switchess1*** Starting CLI:mininet>
3. 查看網路節點
mininet> nodesavailable nodes are:c0 h1 h2 h3 s1mininet>
4. 查看物理拓撲
mininet> neth1 h1-eth0:s1-eth1h2 h2-eth0:s1-eth2h3 h3-eth0:s1-eth3s1 lo: s1-eth1:h1-eth0 s1-eth2:h2-eth0 s1-eth3:h3-eth0c0mininet>
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:2mininet@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:2cookie=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 h2PING 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 h2PING 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 ms64 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 1002msrtt 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 portsport 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=0port 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=0port 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=0port 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:0000000000000001n_tables:254, n_buffers:256capabilities: FLOW_STATS TABLE_STATS PORT_STATS QUEUE_STATS ARP_MATCH_IPactions: 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 ENQUEUE1(s1-eth1): addr:66:9a:a3:e0:64:8fconfig: 0state: 0current: 10GB-FD COPPERspeed: 10000 Mbps now, 0 Mbps max2(s1-eth2): addr:26:bb:36:e0:99:4econfig: 0state: 0current: 10GB-FD COPPERspeed: 10000 Mbps now, 0 Mbps max3(s1-eth3): addr:76:c9:ca:0e:92:4bconfig: 0state: 0current: 10GB-FD COPPERspeed: 10000 Mbps now, 0 Mbps maxLOCAL(s1): addr:fe:3f:bf:f6:26:42config: 0state: 0speed: 0 Mbps now, 0 Mbps maxOFPT_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:2dpctl 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 h2PING 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 ms64 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=0.915 ms64 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 2005msrtt 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:2dpctl 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:2cookie=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 h2PING 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:2cookie=0x0, duration=1280.486s, table=0, n_packets=8, n_bytes=784, idle_age=727, ip,nw_dst=10.0.0.1 actions=output:1cookie=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 h2PING 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 ms64 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=0.991 ms64 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 2006msrtt 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 hostsh1 h2*** Starting controller*** Starting 1 switchess1*** 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 reachabilityh1 -> h2h2 -> 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的拓撲的代碼:
#!/usr/bin/pythonfrom mininet.topo import Topofrom mininet.net import Mininetfrom mininet.util import dumpNodeConnectionsfrom mininet.log import setLogLevel
# 用from import導入python的模塊
class SingleSwitchTopo(Topo):def __init__(self, n=2, **opts)Topo.__init__(self, **opts) # 初始化拓撲以及默認的optionswitch = 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 默認輸出級別為infosimpleTest()
驗證:
# 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 hostsh1 h2 h3 h4*** Starting controller*** Starting 1 switchess1Dumping host connectionsh1 h1-eth0:s1-eth1h2 h2-eth0:s1-eth2h3 h3-eth0:s1-eth3h4 h4-eth0:s1-eth4Testing network connectivity*** Ping: testing ping reachabilityh1 -> h2 h3 h4h2 -> h1 h3 h4h3 -> h1 h2 h4h4 -> h1 h2 h3*** Results: 0% dropped (12/12 received)
推薦閱讀:
※他給多個科學領域當爹,離世30年才拿第一個獎
※數據中心網路架構淺談(二)
※沒有電腦的時候,銀行櫃員是如何處理顧客的存取款業務的?