一、業務背景
目前,vivo 平臺有很多的業務都涉及到文件的下載:譬如說應用商店、遊戲中心的C端用戶下載更新應用或遊戲;開放平臺B端用戶通過接口傳包能力更新應用或遊戲,需要從用戶服務器上下載apk、圖片等文件,來完成用戶的一次版本更新。
二、面臨的挑戰
針對上述C端用戶,平臺需要提供良好的下載環境,並且客戶端需要兼容手機上用戶的異常操作。
針對上述B端用戶,平臺亟需解決的問題就是從用戶服務器上,拉取各種資源文件。
下載本身也是一個很複雜的問題,會涉及到網絡問題、URL重定向、超大文件、遠程服務器文件變更、本地文件被刪除等各種問題。這就需要我們保證平臺具備快速下載文件的能力,同時兼具有有對異常場景的快速預警、容錯處理的機制。
三、業務實現方案
基於前面提到的挑戰,我們設計實現方案的時候,引用了行業常用的解決方法:斷點下載。
針對B端用戶場景,我們的處理方案入下圖:
一、極速下載:通過分析文件大小,智能選擇是否採用直接下載、單線程斷點下載、多線程斷點下載的方案;在使用多線程下載方案時,對"多線程"的使用,有兩種方式:
- 分組模式:單個文件採用固定最大N個線程來進行下載,分組的好處是能保證服務節點線程數量可控,劣勢就是遇到大文件的時候,下載耗時相對會比較長;
- 分片模式:採用單個線程,固定下載N個字節大小空間,分片的好處是遇到大文件的時候,下載耗時仍然會相對短,劣勢是會導致服務器節點線程數量突增,對服務節點穩定性有干擾;
在二者之間,我們選擇了分組模式。
二、容錯處理:在我們處理下載過程中,會遇到下載過程中網絡不穩定、本地文件刪除,遠程文件變更等各種場景,這就需要我們能夠兼容處理這些場景,失敗後的任務,會有定時任務自動重新調起執行,也有後臺管理系統界面,進行人工調起;
三、完整性校驗:文件下載完成之後,需要對文件的最終一致性做校驗,來確保文件的正確性;
四、異常預警:對於單次任務在嘗試多次下載操作後仍然失敗的情況,及時發起預警警告。
對於C端用戶,業務方案相對更簡單,因爲文件服務器有vivo平臺提供,網絡環境相對可控,這裏就不再贅述。接下來,我們將對文件下載裏面的各種技術細節,進行詳盡的剖析。
四、斷點下載原理剖析
在進行原理分析前,先給大家普及一下,什麼叫斷點下載?相信大家都有過使用迅雷下載網絡文件的經歷吧,有沒有注意到迅雷的下載任務欄裏面,有一個“暫停”和“開始下載”按鈕,會隨着任務的當前狀態顯示不同的按鈕。當你在下載一個100M的文件,下載到50M的時候,你點擊了“暫停”,然後點擊了“開始下載”,你會發現文件的下載竟然是從已經下載好的50M以後接着下載的。沒錯,這就是斷點下載的真實應用。
4.1 HTTP 斷點下載之祕密:Range
在講解這個知識點前,大家有必要了解一下http的發展歷史,HTTP(HyperText Transfer Protocol),超文本傳輸協議,是目前萬維網(World Wide Web)的基礎協議,已經經歷四次的版本迭代:HTTP/0.9,HTTP/1.0,HTTP/1.1,HTTP/2.0。在HTTP/1.1(RFC2616)協議中,定義了HTTP1.1標準所包含的所有頭字段的相關語法和含義,其中就包括咱們要講到的Accept-Ranges,服務端支持範圍請求(range requests)。有了這個重要的屬性,才使得我們的斷點下載成爲可能。
基於HTTP不同版本之間的適配性,所以當我們在決定是否需要使用斷點下載能力的時候,需要提前識別文件地址是否支持斷點下載,怎麼識別呢?方法很多,如果採用curl命令,命令爲:curl -I url
CURL驗證是否支持範圍請求:
如果服務端的響應信息裏面包含了上圖中Accept-Ranges: bytes,這個屬性,那麼說該URL是支持範圍請求的。如果URL返回消息體裏面,Accept-Ranges: none 或者壓根就沒有 Accept-Ranges這個屬性,那麼這個URL就是不支持範圍請求,也就是不支持斷點下載。
前面我們有看到,當使用curl命令獲取URL的響應時,服務端返回了一大段文本信息,我們要實現文件的斷點下載,就要從這些文本信息裏面獲取咱們斷點下載需要的重要參數,有了這些參數後才能實現我們想要達到的效果。
4.2 HTTP 斷點下載之Range語法說明
HTTP/1.1 中定義了一個 Range 的請求頭,來指定請求實體的範圍。它的範圍取值是在 0 - Content-Length 之間,使用 - 分割。
4.2.1 單區間段範圍請求
curl https://swsdl.vivo.com.cn/appstore/test-file-range-download.txt -i -H "Range: bytes=0-100"
HTTP/1.1 206 Partial Content
Date: Sun, 20 Dec 2020 03:06:43 GMT
Content-Type: text/plain
Content-Length: 101
Connection: keep-alive
Server: AliyunOSS
x-oss-request-id: 5FDEBFC33243A938379F9410
Accept-Ranges: bytes
ETag: "1FFD36BD1B06EB6C287AF8D788458808"
Last-Modified: Sun, 20 Dec 2020 03:04:33 GMT
x-oss-object-type: Normal
x-oss-hash-crc64ecma: 5148872045942545519
x-oss-storage-class: Standard
Content-MD5: H/02vRsG62woevjXiEWICA==
x-oss-server-time: 2
Content-Range: bytes 0-100/740
X-Via: 1.1 PShnzssxek171:14 (Cdn Cache Server V2.0), 1.1 x71:12 (Cdn Cache Server V2.0), 1.1 PS-FOC-01z6n168:27 (Cdn Cache Server V2.0)
X-Ws-Request-Id: 5fdebfc3_PS-FOC-01z6n168_36519-1719
Access-Control-Allow-Origin: *
4.2.2 多區間段範圍請求
curl https://swsdl.vivo.com.cn/appstore/test-file-range-download.txt -i -H "Range: bytes=0-100,200-300"
HTTP/1.1 206 Partial Content
Date: Sun, 20 Dec 2020 03:10:27 GMT
Content-Type: multipart/byteranges; boundary="Cdn Cache Server V2.0:37E1D9B3B2B94DF2F1D84393694C7E8A"
Content-Length: 506
Connection: keep-alive
Server: AliyunOSS
x-oss-request-id: 5FDEC030BDB66C33302A497E
Accept-Ranges: bytes
ETag: "1FFD36BD1B06EB6C287AF8D788458808"
Last-Modified: Sun, 20 Dec 2020 03:04:33 GMT
x-oss-object-type: Normal
x-oss-hash-crc64ecma: 5148872045942545519
x-oss-storage-class: Standard
Content-MD5: H/02vRsG62woevjXiEWICA==
x-oss-server-time: 2
Age: 1
X-Via: 1.1 xian23:7 (Cdn Cache Server V2.0), 1.1 PS-NTG-01KKN43:8 (Cdn Cache Server V2.0), 1.1 PS-FOC-01z6n168:27 (Cdn Cache Server V2.0)
X-Ws-Request-Id: 5fdec0a3_PS-FOC-01z6n168_36013-8986
Access-Control-Allow-Origin: *
--Cdn Cache Server V2.0:37E1D9B3B2B94DF2F1D84393694C7E8A
Content-Type: text/plain
Content-Range: bytes 0-100/740
--Cdn Cache Server V2.0:37E1D9B3B2B94DF2F1D84393694C7E8A
Content-Type: text/plain
Content-Range: bytes 200-300/740
看完上述請求的響應結果信息,我們發現使用單範圍區間請求時:Content-Type: text/plain,使用多範圍區間請求時:Content-Type: multipart/byteranges; boundary="Cdn Cache Server V2.0:37E1D9B3B2B94DF2F1D84393694C7E8A",並且在尾部信息裏面,攜帶了單個區間片段的Content-Type和Content-Range。另外,不知道大家有沒有發現一個很重要的信息,咱們的HTTP響應的狀態並非我們預想中的200,而是HTTP/1.1 206 Partial Content,這個狀態碼非常重要,因爲它標識着當次下載是否支持範圍請求。
4.3 異常場景之資源變更
有一種場景,不知道大家有沒有思考過,就是我們在下載一個大文件的時候,在未下載完成的時候,遠程文件已經發生了變更,如果我們繼續使用斷點下載,會出現什麼樣的問題?結果當然是文件與遠程文件不一致,會導致文件不可用。那麼我們有什麼辦法能夠在下載之前及時發現遠程文件已經變更,並及時進行調整下載方案呢?解決方法其實上面有給大家提到,遠程文件有沒有發生變化,有兩個標識:Etag和Last-Modified。二者任意一個屬性均可反應出來,相比而言,Etag會更精準些,原因如下:
- Last-Modified只能精確到秒級別,如果一秒內文件進行了多次修改,時間不會發生更新,但是文件的內容卻已經發生了變更,此時Etag會及時更新識別到變更;
- 在不同的時間節點(超過1秒),如果文件從A狀態改成B狀態,然後又重B狀態改回了A狀態,時間會發生更新,但是相較於A狀態文件內容,兩次變更後並沒又發生變化,此時Etag會變回最開始A狀態值,有點類似咱們併發編程裏面常說的ABA問題。
如果我們在進行範圍請求下載的時候,帶上了這兩個屬性中的一個或兩個,就能監控遠程文件發生了變化。如果發生了變化,那麼區間範圍請求的響應狀態就不是206而是200,說明它已經不支持該次請求的斷點下載了。接下來我們驗證一下Etag的驗證信息,我們的測試文件:ETag: "1FFD36BD1B06EB6C287AF8D788458808",然後我們將最後一個數值8改成9進行驗證,驗證如下:
文件未變更:
curl -I --header 'If-None-Match: "1FFD36BD1B06EB6C287AF8D788458808"' https://swsdl.vivo.com.cn/appstore/test-file-range-download.txt
HTTP/1.1 304 Not Modified
Date: Sun, 20 Dec 2020 03:53:03 GMT
Content-Type: text/plain
Connection: keep-alive
Last-Modified: Sun, 20 Dec 2020 03:04:33 GMT
ETag: "1FFD36BD1B06EB6C287AF8D788458808"
Age: 1
X-Via: 1.1 PS-FOC-01vM6221:15 (Cdn Cache Server V2.0)
X-Ws-Request-Id: 5fdeca9f_PS-FOC-01FMC220_2660-18267
Access-Control-Allow-Origin: *
文件已變更:
curl -I --header 'If-None-Match: "1FFD36BD1B06EB6C287AF8D788458809"' https://swsdl.vivo.com.cn/appstore/test-file-range-download.txt
HTTP/1.1 200 OK
Date: Sun, 20 Dec 2020 03:53:14 GMT
Content-Type: text/plain
Content-Length: 740
Connection: keep-alive
Server: AliyunOSS
x-oss-request-id: 5FDEC837E677A23037926897
Accept-Ranges: bytes
ETag: "1FFD36BD1B06EB6C287AF8D788458808"
Last-Modified: Sun, 20 Dec 2020 03:04:33 GMT
x-oss-object-type: Normal
x-oss-hash-crc64ecma: 5148872045942545519
x-oss-storage-class: Standard
Content-MD5: H/02vRsG62woevjXiEWICA==
x-oss-server-time: 17
X-Cache-Spec: Yes
Age: 1
X-Via: 1.1 xian23:7 (Cdn Cache Server V2.0), 1.1 PS-NTG-01KKN43:8 (Cdn Cache Server V2.0), 1.1 PS-FOC-01vM6221:15 (Cdn Cache Server V2.0)
X-Ws-Request-Id: 5fdecaaa_PS-FOC-01FMC220_4661-42392
Access-Control-Allow-Origin: *
結果顯示:當我們使用跟遠程文件一致的Etag時,狀態碼返回:HTTP/1.1 304 Not Modified,而使用篡改後的Etag後,返回狀態200,並且也攜帶了正確的Etag返回。所以我們在使用斷點下載過程中,對於這種資源變更的場景也是需要兼顧考慮的,不然就會出現下載後文件無法使用情況。
4.4 完整性驗證
文件在下載完成後,我們是不是就能直接使用呢?答案:NO。因爲我們無法確認文件是否跟遠程文件完全一致,所以在使用前,一定要做一次文件的完整性驗證。驗證方法很簡單,就是咱們前面提到過的屬性:Etag,資源版本的標識符,通常是消息摘要。帶雙引號的32位字符串,筆者驗證過,該屬性移除雙引號後,就是文件的MD5值,大家知道,文件MD5是可以用來驗證文件唯一性的標識。通過這個校驗,就能很好的識別解決本地文件被刪除、遠程資源文件變更的各類非常規的業務場景。
五、實踐部分
5.1 單線程斷點下載
假如我們需要下載1000個字節大小的文件,那麼我們在開始下載的時候,首先會獲取到文件的Content-Length,然後在第一次開始下載時,會使用參數:httpURLConnection.setRequestProperty("Range", "bytes=0-1000");
當下載到到150個字節大小的時候,因爲網絡問題或者客戶端服務重啓等情況,導致下載終止,那麼本地就存在一個大小爲150byte的不完整文件,當我們服務重啓後重新下載該文件時,我們不僅需要重新獲取遠程文件的大小,還需要獲取本地已經下載的文件大小,此時使用參數:httpURLConnection.setRequestProperty("Range", "bytes=150-1000");
來保證我們的下載是基於前一次的下載基礎之上的。圖示:
5.2 多線程斷點下載
多線程斷點下載的原理,與上面提到的單線程類似,唯一的區別在於:多個線程並行下載,單線程是串行下載。
5.3 代碼示例
5.3.1 獲取連接
在下載前,我們需要獲取遠程文件的HttpURLConnection 連接,如下:
/**
* 獲取連接
*/
private static HttpURLConnection getHttpUrlConnection(String netUrl) throws Exception {
URL url = new URL(netUrl);
HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection();
// 設置超時間爲3秒
httpURLConnection.setConnectTimeout(3 * 1000);
// 防止屏蔽程序抓取而返回403錯誤
httpURLConnection.setRequestProperty("User-Agent", "Mozilla/4.0 (compatible; MSIE 5.0; Windows NT; DigExt)");
return httpURLConnection;
}
5.3.2 是否支持範圍請求
在進行斷點下載開始前,我們需要判斷該文件,是否支持範圍請求,支持的範圍請求,我們才能實現斷點下載,如下:
/**
* 判斷連接是否支持斷點下載
*/
private static boolean isSupportRange(String netUrl) throws Exception {
HttpURLConnection httpURLConnection = getHttpUrlConnection(netUrl);
String acceptRanges = httpURLConnection.getHeaderField("Accept-Ranges");
if (StringUtils.isEmpty(acceptRanges)) {
return false;
}
if ("bytes".equalsIgnoreCase(acceptRanges)) {
return true;
}
return false;
}
5.3.3 獲取遠程文件大小
當文件支持斷點下載,我們需要獲取遠程文件的大小,來設置Range參數的範圍區間,當然,如果是單線程斷線下載,不獲取遠程文件大小,使用 Range: start- 也是能完成斷點下載的,如下:
/**
* 獲取遠程文件大小
*/
private static int getFileContentLength(String netUrl) throws Exception {
HttpURLConnection httpUrlConnection = getHttpUrlConnection(netUrl);
int contentLength = httpUrlConnection.getContentLength();
closeHttpUrlConnection(httpUrlConnection);
return contentLength;
}
5.3.4 單線程斷點下載
不管是單線程斷點下載還是多線程斷點下載,片段文件下載完成後,都無法繞開的一個問題,那就是文件合併。我們使用範圍請求,拿到了文件中的某個區間片段,最終還是要將各個片段合併成一個完整的文件,才能實現我們最初的下載目的。
相較而言,單線程的合併會比較簡單,因爲單線程斷點下載使用串行下載,在文件斷點寫入過程中,都是基於已有片段進行尾部追加,我們使用commons-io-2.4.jar裏面的一個工具方法,來實現文件的尾部追加:
5.3.4.1 文件分段
單線程-範圍分段
/**
* 單線程串行下載
*
* @param totalFileSize 文件總大小
* @param netUrl 文件地址
* @param N 串行下載分段次數
*/
private static void segmentDownload(int totalFileSize, String netUrl, int N) throws Exception {
// 本地文件目錄
String localFilePath = "F:\\test_single_thread.txt";
// 文件我們分N次來下載
int eachFileSize = totalFileSize / N;
for (int i = 1; i <= N; i++) {
// 寫入本地文件
File localFile = new File(localFilePath);
// 獲取本地文件,如果爲空,則start=0,不爲空則爲該本地文件的大小作爲斷點下載開始位置
long start = localFile.length();
long end = 0;
if (i == 1) {
end = eachFileSize;
} else if (i == N) {
end = totalFileSize;
} else {
end = eachFileSize * i;
}
appendFile(netUrl, localFile, start, end);
System.out.println(String.format("我是第%s次下載,下載片段範圍start=%s,end=%s", i, start, end));
}
File localFile = new File(localFilePath);
System.out.println("本地文件大小:" + localFile.length());
}
5.3.4.2 文件追加
單線程-文件尾部追加
/**
* 文件尾部追加
* @param netUrl 地址
* @param localFile 本地文件
* @param start 分段開始位置
* @param end 分段結束位置
*/
private static void appendFile(String netUrl, File localFile, long start, long end) throws Exception {
HttpURLConnection httpURLConnection = getHttpUrlConnection(netUrl);
httpURLConnection.setRequestProperty("Range", "bytes=" + start + "-" + end);
// 獲取遠程文件流信息
InputStream inputStream = httpURLConnection.getInputStream();
// 本地文件寫入流,支持文件追加
FileOutputStream fos = FileUtils.openOutputStream(localFile, true);
IOUtils.copy(inputStream, fos);
closeHttpUrlConnection(httpURLConnection);
}
單線程下載結果
遠程文件支持斷點下載
遠程文件大小:740
我是第1次下載,下載片段範圍start=0,end=246
我是第2次下載,下載片段範圍start=247,end=492
我是第3次下載,下載片段範圍start=493,end=740
本地文件和遠程文件一致,md5 = 1FFD36BD1B06EB6C287AF8D788458808, Etag = "1FFD36BD1B06EB6C287AF8D788458808"
5.3.5 多線程斷點下載
多線程的文件合併方式與單線程不一樣,因爲多線程是並行下載,每個子線程下載完成的時間是不確定的。這個時候,我們需要使用到java一個核心類:RandomAccessFile。這個類可以支持隨機的文件讀寫,其中有一個seek函數,可以將指針指向文件任意位置,然後進行讀寫。什麼意思呢,舉個栗子:假如我們開了10個線程,首先第一個下載完成的是線程X,它下載的數據範圍是300-400,那麼這時我們調用seek函數將指針動到300,然後調用它的write函數將byte寫出,這時候300之前都是NULL,300-400之後就是我們插入的數據。這樣就可以實現多線程下載和本地寫入了。話不多說,我們還是以代碼的方式來呈現:
5.3.5.1 資源分組
多線程-資源分組
/**
* 多線程分組策略
* @param netUrl 網絡地址
* @param totalFileSize 文件總大小
* @param N 線程池數量
*/
private static void groupDownload(String netUrl, int totalFileSize, int N) throws Exception {
// 採用閉鎖特性來實現最後的文件校驗事件
CountDownLatch countDownLatch = new CountDownLatch(N);
// 本地文件目錄
String localFilePath = "F:\\test_multiple_thread.txt";
int groupSize = totalFileSize / N;
int start = 0;
int end = 0;
for (int i = 1; i <= N; i++) {
if (i <= 1) {
start = groupSize * (i - 1);
end = groupSize * i;
} else if (i > 1 && i < N) {
start = groupSize * (i - 1) + 1;
end = groupSize * i;
} else {
start = groupSize * (i - 1) + 1;
end = totalFileSize;
}
System.out.println(String.format("線程%s分配區間範圍start=%s, end=%s", i, start, end));
downloadAndMerge(i, netUrl, localFilePath, start, end, countDownLatch);
}
// 校驗文件一致性
countDownLatch.await();
validateCompleteness(localFilePath, netUrl);
}
5.3.5.2 文件合併
多線程-文件合併
/**
* 文件下載、合併
* @param threadNum 線程標識
* @param netUrl 網絡文件地址
* @param localFilePath 本地文件路徑
* @param start 範圍請求開始位置
* @param end 範圍請求結束位置
* @param countDownLatch 閉鎖對象
*/
private static void downloadAndMerge(int threadNum, String netUrl, String localFilePath, int start, int end, CountDownLatch countDownLatch) {
threadPoolExecutor.execute(() -> {
try {
HttpURLConnection httpURLConnection = getHttpUrlConnection(netUrl);
httpURLConnection.setRequestProperty("Range", "bytes=" + start + "-" + end);
// 獲取遠程文件流信息
InputStream inputStream = httpURLConnection.getInputStream();
RandomAccessFile randomAccessFile = new RandomAccessFile(localFilePath, "rw");
// 文件寫入開始位置指針移動到已經下載位置
randomAccessFile.seek(start);
byte[] buffer = new byte[1024 * 10];
int len = -1;
while ((len = inputStream.read(buffer)) != -1) {
randomAccessFile.write(buffer, 0, len);
}
closeHttpUrlConnection(httpURLConnection);
System.out.println(String.format("下載完成時間%s, 線程:%s, 下載完成: start=%s, end = %s", System.currentTimeMillis(), threadNum, start, end));
} catch (Exception e) {
System.out.println(String.format("片段下載異常:線程:%s, start=%s, end = %s", threadNum, start, end));
e.printStackTrace();
}
countDownLatch.countDown();
});
}
多線程下載運行結果
遠程文件支持斷點下載
遠程文件大小:740
線程1分配區間範圍start=0, end=74
線程2分配區間範圍start=75, end=148
線程3分配區間範圍start=149, end=222
線程4分配區間範圍start=223, end=296
線程5分配區間範圍start=297, end=370
線程6分配區間範圍start=371, end=444
線程7分配區間範圍start=445, end=518
線程8分配區間範圍start=519, end=592
線程9分配區間範圍start=593, end=666
線程10分配區間範圍start=667, end=740
下載完成時間1608443874752, 線程:7, 下載完成: start=445, end = 518
下載完成時間1608443874757, 線程:2, 下載完成: start=75, end = 148
下載完成時間1608443874758, 線程:3, 下載完成: start=149, end = 222
下載完成時間1608443874759, 線程:5, 下載完成: start=297, end = 370
下載完成時間1608443874760, 線程:10, 下載完成: start=667, end = 740
下載完成時間1608443874760, 線程:1, 下載完成: start=0, end = 74
下載完成時間1608443874779, 線程:8, 下載完成: start=519, end = 592
下載完成時間1608443874781, 線程:6, 下載完成: start=371, end = 444
下載完成時間1608443874784, 線程:9, 下載完成: start=593, end = 666
下載完成時間1608443874788, 線程:4, 下載完成: start=223, end = 296
本地文件和遠程文件一致,md5 = 1FFD36BD1B06EB6C287AF8D788458808, Etag = "1FFD36BD1B06EB6C287AF8D788458808"
從運行結果可以出,子線程下載完成時間並沒有完全按着我們for循環指定的1-10線程標號順序完成,說明子線程之間是並行在寫入文件。其中還可以看到,子線程10和子線程1是在同一時間完成了文件的下載和寫入,這也很好的驗證了我們上面提到的RandomAccessFile類的效果。
5.3.6 完整性判斷
完整性校驗
/**
* 校驗文件一致性,我們判斷Etag和本地文件的md5是否一致
* 注:Etag攜帶了雙引號
* @param localFilePath
* @param netUrl
*/
private static void validateCompleteness(String localFilePath, String netUrl) throws Exception{
File file = new File(localFilePath);
InputStream data = new FileInputStream(file);
String md5 = DigestUtils.md5Hex(data);
HttpURLConnection httpURLConnection = getHttpUrlConnection(netUrl);
String etag = httpURLConnection.getHeaderField("Etag");
if (etag.toUpperCase().contains(md5.toUpperCase())) {
System.out.println(String.format("本地文件和遠程文件一致,md5 = %s, Etag = %s", md5.toUpperCase(), etag));
} else {
System.out.println(String.format("本地文件和遠程文件不一致,md5 = %s, Etag = %s", md5.toUpperCase(), etag));
}
}
六、寫在最後
文件斷點下載的優勢在於提升下載速度,但是也不是每種業務場景都適合,比如說業務網絡環境很好,下載的單個文件大小几十兆的情況下,使用斷點下載也沒有太大的優勢,反而增加了實現方案的複雜度。這就要求我們開發人員在使用時酌情考慮,而不是盲目使用。
作者:vivo-Tang Aibo