第3章:Hadoop分布式文件系統(tǒng)(2)

數(shù)據(jù)流

讀取文件數(shù)據(jù)的剖析

為了知道客戶端與HDFS,NameNode,DataNode交互過程中數(shù)據(jù)的流向,請看圖3-2,這張圖顯示了讀取文件過程中主要的事件順序。
圖3-2 客戶端從HDFS讀取數(shù)據(jù)

客戶端通過調(diào)用FileSystem對象的open()方法打開一個希望從中讀取數(shù)據(jù)的文件,對于HDFS來說,F(xiàn)ileSystem是一個DistributedFileSystem的實(shí)例對象(圖3-2 步驟1)。DistributedFileSystem遠(yuǎn)程調(diào)用名稱節(jié)點(diǎn)(NameNode)得到文件開頭幾個塊的位置。對于每一個塊,名稱節(jié)點(diǎn)返回包含這個塊復(fù)本的所有數(shù)據(jù)節(jié)點(diǎn)(DataNode)的地址。進(jìn)一步,這些數(shù)據(jù)節(jié)點(diǎn)會根據(jù)集群的網(wǎng)絡(luò)拓?fù)浣Y(jié)構(gòu)按照距離客戶端的遠(yuǎn)近進(jìn)行排序。如果客戶端本身是一個數(shù)據(jù)節(jié)點(diǎn)(例如一個MapReduce任務(wù)),而這個數(shù)據(jù)節(jié)點(diǎn)包含要讀取的塊的復(fù)本,則客戶端會直接從本地讀取。

DistributedFileSystem返回一個FSDataInputStream對象給客戶端,用于從文件中讀取數(shù)據(jù)。FSDataInputStream是一個輸入流,支持文件尋位(seek)。FSDataInputStream里包裝了一個DFSInputStream類,這個類支持?jǐn)?shù)據(jù)節(jié)點(diǎn)和名稱節(jié)點(diǎn)的I/O操作。

客戶端調(diào)用read()方法從流中讀取數(shù)據(jù)。DFSInputStream存儲了文件中開頭幾個塊所在的數(shù)據(jù)節(jié)點(diǎn)的地址。首先連接第一個塊所在的最近的數(shù)據(jù)節(jié)點(diǎn),數(shù)據(jù)從數(shù)據(jù)節(jié)點(diǎn)被讀取到客戶端,然后不斷地從這個流中讀取(步驟4)直接這個塊數(shù)據(jù)被讀完,然后DFSInputStream將會關(guān)閉到這個數(shù)據(jù)節(jié)點(diǎn)的連接,尋找下一個塊所在的最近的數(shù)據(jù)節(jié)點(diǎn)(步驟5)。這一系列操作對客戶端來說是透明的,它不用管。從客戶端的角度來看,它僅僅是在讀取一個連續(xù)的數(shù)據(jù)流。

塊按順序依次被讀取。當(dāng)客戶端從數(shù)據(jù)流中讀數(shù)的時候,DFSInputStream依次建立和關(guān)閉和數(shù)據(jù)節(jié)點(diǎn)的連接。如果需要,DistributedFileSystem將再次調(diào)用名稱節(jié)點(diǎn)得到下一批塊所有數(shù)據(jù)節(jié)點(diǎn)的位置。當(dāng)客戶端完成了所有數(shù)據(jù)的讀取,它會調(diào)用FSDataInputStream的close()方法關(guān)閉流(步驟6)。

在讀取的過程中,如果DFSInputStream在與數(shù)據(jù)節(jié)點(diǎn)交互的過程中出現(xiàn)了錯誤,它將會嘗試當(dāng)前塊所在的最近的下一個數(shù)據(jù)節(jié)點(diǎn)。它也會記住那些交互失敗的數(shù)據(jù)節(jié)點(diǎn)以便讀取其它塊時不再在這些失敗的數(shù)據(jù)節(jié)點(diǎn)中讀取。DFSInputStream也會校驗(yàn)從數(shù)據(jù)節(jié)點(diǎn)傳過來的數(shù)據(jù),如果塊中數(shù)據(jù)損壞了,它將嘗試從另一個包含這個塊復(fù)本的數(shù)據(jù)節(jié)點(diǎn)中讀取。它也會向名稱節(jié)點(diǎn)報告這個損壞的塊。

這樣設(shè)計一個重要的方面是客戶端直接與數(shù)據(jù)節(jié)點(diǎn)交互,并通過名稱節(jié)點(diǎn)的引導(dǎo),找到每一個塊所在的最好的數(shù)據(jù)節(jié)點(diǎn)。這樣設(shè)計可以讓HDFS響應(yīng)大量同時并發(fā)請求的客戶端。因?yàn)閿?shù)據(jù)分布在集群中所有的數(shù)據(jù)節(jié)點(diǎn)中。而且,名稱節(jié)點(diǎn)僅僅需要響應(yīng)獲取塊所有位置的請求(這個位置信息存儲在內(nèi)存中,所以非常高效)而不需要響應(yīng)獲取文件數(shù)據(jù)的請求。如果名稱節(jié)點(diǎn)還響應(yīng)讀取文件數(shù)據(jù)的請求,那么隨著客戶端數(shù)據(jù)增多,很快會出現(xiàn)瓶頸。

Hadoop網(wǎng)絡(luò)拓?fù)浣Y(jié)構(gòu)
本地網(wǎng)絡(luò)的兩個節(jié)點(diǎn)對彼此"關(guān)閉"是什么意思呢?在大批量數(shù)據(jù)處理環(huán)境中,限制速度的因素是節(jié)點(diǎn)之前傳輸?shù)乃俾剩瑤拵缀鯇λ俣葲]有一點(diǎn)貢獻(xiàn),所以可以用節(jié)點(diǎn)間的帶寬做為衡量節(jié)點(diǎn)間距離的尺碼。但在實(shí)踐中并不直接去測試兩個節(jié)點(diǎn)間的帶寬,因?yàn)檫@很困難。Hadoop采取了一個簡單的途徑,網(wǎng)絡(luò)以樹的形式表示,兩個節(jié)點(diǎn)的距離等于各自距離他們共同上層節(jié)點(diǎn)的距離之和。樹中的層級并不是預(yù)先設(shè)定好的,通常層級中有數(shù)據(jù)中心,機(jī)架(Rack)和正在運(yùn)行進(jìn)程的節(jié)點(diǎn)。下面場景中帶寬依次遞減:

  • 相同節(jié)點(diǎn)上的處理
  • 同一機(jī)架不同節(jié)點(diǎn)上的處理
  • 同一數(shù)據(jù)中心不同機(jī)架中節(jié)點(diǎn)上的處理
  • 不同數(shù)據(jù)中心中節(jié)點(diǎn)上的處理
    例如:節(jié)點(diǎn)n1,在機(jī)架r1上,機(jī)架在數(shù)據(jù)中心d1上。用/d1/r1/n1,以這為列,來看看下面四個場景中節(jié)點(diǎn)間距離:
  • distance(/d1/r1/n1,/d1/r1/n1)=0(相同節(jié)點(diǎn)上的處理)
  • distance(/d1/r1/n1,/d1/r1/n2)=2(相同機(jī)架上不同節(jié)點(diǎn))
  • distance(/d1/r1/n1,/d1/r2/n3)=4(相同數(shù)據(jù)中心不同節(jié)點(diǎn))
  • distance(/d1/r1/n1,/d2/r3/n4)=6(不同數(shù)據(jù)中心節(jié)點(diǎn))

圖3-3更加形象顯示了上面示例:
圖3-3:hadoop中節(jié)點(diǎn)間距離

最后,你要知道hadoop并不知道你的網(wǎng)絡(luò)拓?fù)鋱D,需要你進(jìn)行配置。然而,默認(rèn)的情況下,hadoop會假設(shè)所有節(jié)點(diǎn)在同一數(shù)據(jù)中心中一機(jī)架上。對于小型集群,確實(shí)是這種情況,這樣的話,就不需要進(jìn)行額外的配置。

寫入數(shù)據(jù)到文件的剖析

下一步,我們將看看數(shù)據(jù)怎么寫入到HDFS中的。雖然這是很細(xì)節(jié)的東西,但它有助于理解HDFS模型如何保證數(shù)據(jù)一致。

我們考慮這一種情況,在HDFS中創(chuàng)建一個新文件,寫入數(shù)據(jù),然后關(guān)閉文件。如圖3-4所示:
圖3-4:客戶端向HDFS寫入數(shù)據(jù)

客戶端通過調(diào)用DistributedFileSystem類的create()方法創(chuàng)建文件(圖3-4步驟1)。DistributedFileSystem遠(yuǎn)程調(diào)用名稱節(jié)點(diǎn)在文件系統(tǒng)的名稱空間中創(chuàng)建一個新文件,沒有塊與這個新文件關(guān)聯(lián)(步驟2)。名稱節(jié)點(diǎn)做各種各樣的檢查確保文件之前沒有被創(chuàng)建過,而且客戶端有權(quán)限創(chuàng)建這個文件。如果檢查通過,名稱節(jié)點(diǎn)將會記錄這個新文件,否則將創(chuàng)建失敗,拋給客戶端一個IOException異常。如果成功創(chuàng)建,則DistributedFileSystem返回一個FSDataOutputStream對象給客戶端,以便客戶端寫入數(shù)據(jù)。正如讀數(shù)據(jù)那樣,F(xiàn)SDataOutputStream封閉了DFSOutputStream類,用此類來與數(shù)據(jù)節(jié)點(diǎn)與名稱節(jié)點(diǎn)交互。

當(dāng)客戶端寫數(shù)據(jù)的時候(步驟3),DFSOutputStream首先將數(shù)據(jù)拆分成多個包,寫入"數(shù)據(jù)隊列"中。然后,DataStreamer過來消費(fèi)這個數(shù)據(jù)隊列,它會向名稱節(jié)點(diǎn)請求一些合適的新塊用于存儲復(fù)本數(shù)據(jù)。名稱節(jié)點(diǎn)會返回包含這些新塊的數(shù)據(jù)節(jié)點(diǎn)列表。這些數(shù)據(jù)節(jié)點(diǎn)形成了一個通道,這里,我們假設(shè)復(fù)制級別是3,所以在這個通道中有三個節(jié)點(diǎn)。DataStreamer首先向這個通道中第一個數(shù)據(jù)節(jié)點(diǎn)寫入之前被拆分的包數(shù)據(jù)。第一個數(shù)據(jù)節(jié)點(diǎn)寫完后,會前進(jìn)到第二個數(shù)據(jù)節(jié)點(diǎn),第二個數(shù)據(jù)節(jié)點(diǎn)存儲包數(shù)據(jù)后繼續(xù)前進(jìn)到第三個也是最后一個數(shù)據(jù)節(jié)點(diǎn)(步驟4)。

DFSOutStream也會在內(nèi)部維護(hù)一個"包隊列"。只有當(dāng)某一個包被所有節(jié)點(diǎn)存儲后,這個包才會從包隊列中刪除(步驟5)。

如果在數(shù)據(jù)寫入過程中,任何一個數(shù)據(jù)節(jié)點(diǎn)寫入失敗了,那么將么執(zhí)行如下操作(這些操作對客戶端來說是透明的)。首先,通道關(guān)閉,包隊列中的所有包都將會放到數(shù)據(jù)隊列前面。這樣,失敗數(shù)據(jù)節(jié)點(diǎn)的下游數(shù)據(jù)節(jié)點(diǎn)不會錯過任何一個包。在好的數(shù)據(jù)節(jié)點(diǎn)上的當(dāng)前塊被給予一個新的身份標(biāo)識,將它傳送給名稱節(jié)點(diǎn),以便以后當(dāng)失敗的數(shù)據(jù)節(jié)點(diǎn)恢復(fù)后,它上面已經(jīng)保存的部分塊數(shù)據(jù)將會被刪除。失敗的數(shù)據(jù)節(jié)點(diǎn)從通道中移除,再基于剩下兩個好的數(shù)據(jù)節(jié)點(diǎn)建立一個新通道。數(shù)據(jù)塊中剩余的數(shù)據(jù)寫到管道中剩下好的數(shù)據(jù)節(jié)點(diǎn)中。名稱節(jié)點(diǎn)知道這個塊還需要復(fù)制,所以它會把它復(fù)制到另外一個節(jié)點(diǎn)中.余下的塊照常處理。

雖然不太可能,但在寫入數(shù)據(jù)的時候仍有可能幾個數(shù)據(jù)節(jié)點(diǎn)同時失敗,只要dfs.namenode.replication.min復(fù)本數(shù)(默認(rèn)是1)有值,就會寫入成功。塊將會在集群中異步復(fù)制直到達(dá)到設(shè)定的復(fù)本復(fù)制數(shù)(dfs.replication默認(rèn)是3)。

當(dāng)客戶端寫入數(shù)據(jù)完成后,將會調(diào)用close()方法關(guān)閉流(步驟6)。這個方法將會清除數(shù)據(jù)節(jié)點(diǎn)通道中剩下的包,并等待所有包數(shù)據(jù)寫入完成,然后通知名稱節(jié)點(diǎn),整個文件已經(jīng)寫入完成(步驟7)。名稱節(jié)點(diǎn)知道這個文件由哪些塊組成(因?yàn)镈ataStreamer是向名稱節(jié)點(diǎn)請求得到塊的位置的),所以它僅需要等待塊完成了最小復(fù)制就可以成功返回了。

復(fù)本存儲

名稱節(jié)點(diǎn)是怎么知道選擇哪些數(shù)據(jù)節(jié)點(diǎn)存儲復(fù)本呢?這是在綜合權(quán)衡了可靠性,寫入數(shù)據(jù)帶寬和讀取數(shù)據(jù)帶寬之后得到的結(jié)果。例如:如果將所有復(fù)本放在一個節(jié)點(diǎn)上將會造成最小的寫入帶寬(因?yàn)閺?fù)制通道運(yùn)行在一個節(jié)點(diǎn)上),而且,這不是真正的冗余,因?yàn)槿绻@個節(jié)點(diǎn)損壞了,塊數(shù)據(jù)就會丟失。但是讀數(shù)據(jù)的帶寬會很高。另一種極端的情況,將復(fù)本放在不同的數(shù)據(jù)中心,這樣或許能最大化冗余度,但是卻很消耗帶寬。即使在相同的數(shù)據(jù)中心中,也會有很多種不同的存儲策略。
Hadoop默認(rèn)的策略是將第一個復(fù)本存放在客戶機(jī)所在的節(jié)點(diǎn)中(對于運(yùn)行在集群外的客戶端來說,將會隨機(jī)選擇一個節(jié)點(diǎn),系統(tǒng)盡量不會選擇已經(jīng)存儲很滿或工作太忙的節(jié)點(diǎn))。第二個復(fù)本存儲時將會選擇與第一個節(jié)點(diǎn)不在同一個硬盤陣列的另外一個機(jī)架,隨機(jī)選擇一個節(jié)點(diǎn)存儲。第三個復(fù)本將會放在與第二個節(jié)點(diǎn)相同的機(jī)架中,但是存儲在隨機(jī)選擇的另外一個節(jié)點(diǎn)中。其它的復(fù)本將會存儲在集群中隨機(jī)選擇的節(jié)點(diǎn)中,系統(tǒng)盡量避免將太量復(fù)本放到相同的機(jī)架中。

一旦復(fù)本的存儲位置確定了,就會建立一個通道,結(jié)合考慮hadoop的網(wǎng)絡(luò)拓?fù)浣Y(jié)構(gòu)之后進(jìn)行數(shù)據(jù)的寫入。對于復(fù)本個數(shù)為3的情況,通道也許如圖3-5所示:
圖3-5:一個典型的復(fù)制通道

總之,這個策略在可靠性(塊被存儲在兩個機(jī)架中),寫入帶寬(寫數(shù)據(jù)時僅需要通過一個網(wǎng)絡(luò)交換機(jī)),讀取性能(可以選擇兩個機(jī)架中任意一個讀取),塊的分布性(客戶端僅在本地機(jī)架中寫入一個塊)這些因素之間做了比較好的權(quán)衡。

一致性模型

文件系統(tǒng)的一致性模型描述了讀取文件中的數(shù)據(jù)或向文件寫入數(shù)據(jù)的可見性。HDFS為了性能犧牲了一些POSIX標(biāo)準(zhǔn)的要求,導(dǎo)致一些操作可能與你期望的不一樣。

在創(chuàng)建一個文件后,正如所期望的那樣,在文件系統(tǒng)名稱空間中看見了這個文件。

Path p=new Path("p");
fs.create(p);
assertThat(fs.exists(p),is(true));

然而,任何寫入到這個文件的數(shù)據(jù)不一定可見,即使輸出流被flush刷新了。這個文件的長度仍為0。

Path p=new Path("p");
OutputStream out=fs.create(p);
out.write("content".getBytes("UTF-8"));
out.flush();
assertThat(fs.getFileStatus(p).getLen(),is(0L));

一旦超過一個hadoop塊的數(shù)據(jù)寫入了,第一個塊將對讀取器可見。對于后續(xù)的塊也是如此。當(dāng)前正在被寫入數(shù)據(jù)的塊總是對新來的讀取器不可見。

HDFS通過FSDataOutputStream的hflush()方法可以強(qiáng)迫緩存中的數(shù)據(jù)flush進(jìn)數(shù)據(jù)節(jié)點(diǎn)。在hflush()方法成功返回后,HDFS確保已經(jīng)寫入文件的數(shù)據(jù)都存進(jìn)了寫數(shù)據(jù)管道中的數(shù)據(jù)節(jié)點(diǎn)中,并且對新來的讀取器可見。

Path p=new Path("p");
FSDataOutputStream out=fs.create(p);
out.write("content".getBytes("UTF-8"));
out.hflush();
assertThat(fs.getFileStatus(p).getLen(),is((long)"contents".length()));

注意hflush()不能確保數(shù)據(jù)節(jié)點(diǎn)已經(jīng)將數(shù)據(jù)寫入磁盤中,僅僅確保數(shù)據(jù)存儲在數(shù)據(jù)節(jié)點(diǎn)的內(nèi)存中(所以如果數(shù)據(jù)中心斷電了,數(shù)據(jù)將會丟失)。如果需要確保數(shù)據(jù)能寫入磁盤,請使用hsync()。

hsync()方法內(nèi)部的操作與POSIX標(biāo)準(zhǔn)中fsync()標(biāo)準(zhǔn)命令相似,都會提交緩存中的數(shù)據(jù)到磁盤。例如,使用標(biāo)準(zhǔn)的JAVA API將數(shù)據(jù)寫入本地文件,在flush數(shù)據(jù)流和同步數(shù)據(jù)到磁盤后,就可以確保能看見已經(jīng)寫入文件的內(nèi)容。

FileOutputStream out=new FileOutputStream(localFile);
out.write("contents".getBytes("UTF-8"));
out.flush();//flush操作系統(tǒng)
out.getFD().sync();//同步進(jìn)磁盤
assertThat(localFile.length(),is((long)"contents".length()));

關(guān)閉HDFS的文件流時內(nèi)部也會執(zhí)行hflush()方法。

Path p=new Path("p");
OutputStream out=fs.create(p);
out.write("contents".getBytes("UTF-8"));
out.close();
assertThat(fs.getFileStatus().getLen(),is((long)"contents".length()));

應(yīng)用設(shè)計的重要性

一致性模型已經(jīng)蘊(yùn)涵了設(shè)計應(yīng)用的方法。如果不調(diào)用hflush()和hsync(),當(dāng)客戶端或系統(tǒng)故障時,你將會丟失大量數(shù)據(jù)。對很多應(yīng)用來說,這是不可接受的。所以你應(yīng)該在合適的時機(jī)調(diào)用hflush(),例如在寫入相當(dāng)一部分?jǐn)?shù)據(jù)記錄或字節(jié)之后。雖然hflush()這個方法在設(shè)計時考慮到不對HDFS造成太大負(fù)擔(dān),但是它確實(shí)對性能有一些影響(hsync()有更多影響)。所以在數(shù)據(jù)健壯性與傳輸率之間要有一個權(quán)衡。一個可接受的平衡點(diǎn)是當(dāng)以不同頻率調(diào)用hflush(),并在考量應(yīng)用性能前提下,那些依賴應(yīng)用,合適的數(shù)據(jù)都能被讀取到時。

使用distcp并發(fā)復(fù)制

到目前為止我們看到的HDFS獲取數(shù)據(jù)的形式都是單線程的。例如,通過指定文件通配符的方法,我們可以同時操作大量文件。但要想有效地并發(fā)處理這些文件 ,你必須自己編程。Hadoop提供了一個有用的程序,叫做distcp,用于并發(fā)地將數(shù)據(jù)復(fù)制到hadoop或從Hadoop復(fù)制數(shù)據(jù)。

distcp其中的一個用途是替代hadoop fs -cp命令。例如,你可以復(fù)制一個文件到另一個文件中通過使用

% hadoop distcp file1 file2

你也可以復(fù)制目錄:

% hadoop distcp dir1 dir2

如果目錄dir2不存在,hadoop將會創(chuàng)建它。并且目錄1中的內(nèi)容將復(fù)制到目錄dir2中。你可以指定多個源路徑,所有這些源路徑下的文件都將會復(fù)制到目的目錄中。

如果目錄dir2已經(jīng)存在了,dir1目錄將復(fù)制到它下一級,創(chuàng)建目錄結(jié)構(gòu)dir2/dir1。如果這不是你所想要的,你可以通過使用-overwrite選項(xiàng),將數(shù)據(jù)以覆蓋的形式復(fù)制到dir2目錄下。你也可以只更新那些已經(jīng)改變的文件,使用-update選項(xiàng)。我們通過一個示例說明。如果我們在目錄dir1中修改了一個文件,我們將會使用如下命令將dir1目錄的修改同步進(jìn)dir2中。

% hadoop distcp -update dir1 dir2

distcp使用MapReduce作業(yè)方式實(shí)現(xiàn),在集群中并發(fā)運(yùn)行多個map來進(jìn)行復(fù)制工作,沒有reducer。每一個file使用一個map復(fù)制。Distcp粗略地將所有文件等分成幾份,以便給每一個map分配近似相等的數(shù)據(jù)量。默認(rèn)情況下,最多使用20個map。但是這個值可以通過在distcp中指定-m參數(shù)改變。

使用distcp一個非常常用的用途是在兩個HDFS集群間傳輸數(shù)據(jù)。例如,下面命令在第二個集群中創(chuàng)建了第一個集群/foo目錄下文件的備份。

% hadoop distcp -update -delete -p hdfs://namenode1/foo hdfs://namenod2/foo

-delete參數(shù)使用distcp刪除目的目錄下有而源目錄沒有的文件或目錄。-p參數(shù)意思是文件的狀態(tài)屬性像權(quán)限,塊大小和復(fù)本個數(shù)都保留。你可以不帶任何參數(shù)運(yùn)行distcp命令來查看參數(shù)的詳細(xì)使用說明。

如果這兩個集群運(yùn)行不同版本的HDFS,那么你可以使用webhdfs協(xié)議在兩個集群間復(fù)制。

% hadoop distcp webhdfs://namenode1:50070/foo webhdfs://namenode2:50070/foo

另一種變通的方法可以使用HTTPFS代理做為distcp的源或目的地(它也使用了webhdfs協(xié)議,可以設(shè)置防火墻和控制帶寬,參看"HTTP章節(jié)")。

保持HDFS集群平衡

當(dāng)將數(shù)據(jù)復(fù)制到HDFS中時,考慮集群的平衡性很重要。當(dāng)文件塊在集群中均勻連續(xù)存儲時,HDFS能夠表現(xiàn)地最好。所以你使用distcp時也要確保不打破這個規(guī)則。例如,如果你如果指定-m 1,將會有一個map進(jìn)行復(fù)制工作,先不考慮這樣做效率很低,沒有充分有效地利用集群資源,這樣做就意味著每一個塊的第一個復(fù)本將位于運(yùn)行map任務(wù)的節(jié)點(diǎn)上(直到磁盤滿了)。第二個和第三個復(fù)本將會在集群其它節(jié)點(diǎn)上。但是這樣就達(dá)不到平衡,如果使集群中map任務(wù)數(shù)比節(jié)點(diǎn)數(shù)多,就可以避免這個問題。所以,當(dāng)運(yùn)行distcp命令時,最好使用默認(rèn)的每一個節(jié)點(diǎn)20個map任務(wù)。

然而,不可能一直保持集群平衡。也許你想要限制map任務(wù)的個數(shù),以便節(jié)點(diǎn)上資源能夠被其它作業(yè)使用。這種情況下,你可以使用平衡工具(可參看"平衡器章節(jié)")使集群中的塊分布地更加均衡。

本文是筆者翻譯自《OReilly.Hadoop.The.Definitive.Guide.4th.Edition》第一部分第3章,后續(xù)將繼續(xù)翻譯其它章節(jié)。雖盡力翻譯,但奈何水平有限,錯誤再所難免,如果有問題,請不吝指出!希望本文對你有所幫助。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容