平常在寫業務的時候常常會用的到的是 GET
, POST
請求去請求接口,GET
相關的接口會比較容易基本不會出錯,而對于 POST
中常用的 表單提交,JSON
提交也比較容易,但是對于文件上傳呢?大家可能對這個步驟會比較害怕,因為可能大家對它并不是怎么熟悉,而瀏覽器Network
對它也沒有詳細的進行記錄,因此它成為了我們心中的一根刺,我們老是無法確定,關于文件上傳到底是我寫的有問題呢?還是后端有問題,當然,我們一般都比較謙虛, 總是會在自己身上找原因,可是往往實事呢?可能就出在后端身上,可能是他接受寫的有問題,導致你換了各種請求庫去嘗試,axios
,request
,fetch
等等。那么我們如何避免這種情況呢?我們自身要對這一塊夠熟悉,才能不以猜的方式去寫代碼。如果你覺得我以上說的你有同感,那么你閱讀完這篇文章你將收獲自信,你將不會質疑自己,不會以猜的方式去寫代碼。
本文比較長可能需要花點時間去看,需要有耐心,我采用自頂向下的方式,所有示例會先展現出你熟悉的方式,再一層層往下, 先從請求端是怎么發送文件的,再到接收端是怎么解析文件的。
multipart/form-data
最初由 《RFC 1867: Form-based File Upload in HTML》[1]文檔提出。
Since file-upload is a feature that will benefit many applications, this proposes an extension to HTML to allow information providers to express file upload requests uniformly, and a MIME compatible representation for file upload responses.
由于文件上傳功能將使許多應用程序受益,因此建議對HTML進行擴展,以允許信息提供者統一表達文件上傳請求,并提供文件上傳響應的MIME兼容表示。
總結就是原先的規范不滿足啦,我要擴充規范了。
The encoding type application/x-www-form-urlencoded is inefficient for sending large quantities of binary data or text containing non-ASCII characters. Thus, a new media type,multipart/form-data, is proposed as a way of efficiently sending the values associated with a filled-out form from client to server.
1867文檔中也寫了為什么要新增一個類型,而不使用舊有的application/x-www-form-urlencoded
:因為此類型不適合用于傳輸大型二進制數據或者包含非ASCII字符的數據。平常我們使用這個類型都是把表單數據使用url編碼后傳送給后端,二進制文件當然沒辦法一起編碼進去了。所以multipart/form-data
就誕生了,專門用于有效的傳輸文件。
也許你有疑問?那可以用 application/json
嗎?
其實我認為,無論你用什么都可以傳,只不過會要綜合考慮一些因素的話,multipart/form-data
更好。例如我們知道了文件是以二進制的形式存在,application/json
是以文本形式進行傳輸,那么某種意義上我們確實可以將文件轉成例如文本形式的 Base64
形式。但是呢,你轉成這樣的形式,后端也需要按照你這樣傳輸的形式,做特殊的解析。并且文本在傳輸過程中是相比二進制效率低的,那么對于我們動輒幾十M幾百M的文件來說是速度是更慢的。
以上為什么文件傳輸要用multipart/form-data
我還可以舉個例子,例如你在中國,你想要去美洲,我們的multipart/form-data
相當于是選擇飛機,而application/json
相當于高鐵,但是呢?中國和美洲之間沒有高鐵啊,你執意要坐高鐵去,你可以花昂貴的代價(后端額外解析你的文本)造高鐵去美洲,但是你有更加廉價的方式坐飛機(使用multipart/form-data
)去美洲(去傳輸文件)。你圖啥?(如果你有錢有時間,抱歉,打擾了,老子給你道歉)
摘自 《RFC 1867: Form-based File Upload in HTML》[2] 6.Example
Content-type: multipart/form-data, boundary=AaB03x
--AaB03x
content-disposition: form-data; name="field1"
Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain
... contents of file1.txt ...
--AaB03x--
可以簡單解釋一些,首先是請求類型,然后是一個 boundary (分割符),這個東西是干啥的呢?其實看名字就知道,分隔符,當時分割作用,因為可能有多文件多字段,每個字段文件之間,我們無法準確地去判斷這個文件哪里到哪里為截止狀態。因此需要有分隔符來進行劃分。然后再接下來就是聲明內容的描述是 form-data 類型,字段名字是啥,如果是文件的話,得知道文件名是啥,還有這個文件的類型是啥,這個也很好理解,我上傳一個文件,我總得告訴后端,我傳的是個啥,是圖片?還是一個txt文本?這些信息肯定得告訴人家,別人才好去進行判斷,后面我們也會講到如果這些沒有聲明的時候,會發生什么?
好了講完了這些前置知識,我們接下來要進入我們的主題了。面對File, formData,Blob,Base64,ArrayBuffer,到底怎么做?還有文件上傳不僅僅是前端的事。服務端也可以文件上傳(例如我們利用某云,把靜態資源上傳到 OSS 對象存儲)。服務端和客戶端也有各種類型,Buffer,Stream,Base64....頭禿,怎么搞?不急,就是因為上傳文件不單單是前端的事,所以我將以下上傳文件的一方稱為請求端,接受文件一方稱為接收方。我會以請求端各種上傳方式,接收端是怎么解析我們的文件以及我們最終的殺手锏調試工具-wireshark來進行講解。以下是講解的大綱,我們先從瀏覽器端上傳文件,再到服務端上傳文件,然后我們再來解析文件是如何被解析的。
首先我們先寫下最簡單的一個表單提交方式。
<form action="http://localhost:7787/files" method="POST">
<input name="file" type="file" id="file">
<input type="submit" value="提交">
</form>
我們選擇文件后上傳,發現后端返回了文件不存在。
不用著急,熟悉的同學可能立馬知道是啥原因了。噓,知道了也聽我慢慢叨叨。
我們打開控制臺,由于表單提交會進行網頁跳轉,因此我們勾選preserve log
來進行日志追蹤。
我們可以發現其實 FormData
中 file
字段顯示的是文件名,并沒有將真正的內容進行傳輸。再看請求頭。
發現是請求頭和預期不符,也印證了 application/x-www-form-urlencoded
無法進行文件上傳。
我們加上請求頭,再次請求。
<form action="http://localhost:7787/files" enctype="multipart/form-data" method="POST">
<input name="file" type="file" id="file">
<input type="submit" value="提交">
</form>
發現文件上傳成功,簡單的表單上傳就是像以上一樣簡單。但是你得熟記文件上傳的格式以及類型。
formData 的方式我隨便寫了以下幾種方式。
<input type="file" id="file">
<button id="submit">上傳</button>
<script src="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>
<script>
submit.onclick = () => {
const file = document.getElementById('file').files[0];
var form = new FormData();
form.append('file', file);
// type 1
axios.post('http://localhost:7787/files', form).then(res => {
console.log(res.data);
})
// type 2
fetch('http://localhost:7787/files', {
method: 'POST',
body: form
}).then(res => res.json()).tehn(res => {console.log(res)});
// type3
var xhr = new XMLHttpRequest();
xhr.open('POST', 'http://localhost:7787/files', true);
xhr.onload = function () {
console.log(xhr.responseText);
};
xhr.send(form);
}
</script>
以上幾種方式都是可以的。但是呢,請求庫這么多,我隨便在 npm 上一搜就有幾百個請求相關的庫。
因此,掌握請求庫的寫法并不是我們的目標,目標只有一個還是掌握文件上傳的請求頭和請求內容。
Blob
對象表示一個不可變、原始數據的類文件對象。Blob 表示的不一定是JavaScript原生格式的數據。File
[3] 接口基于Blob
,繼承了 blob 的功能并將其擴展使其支持用戶系統上的文件。
因此如果我們遇到 Blob 方式的文件上方式不用害怕,可以用以下兩種方式:
1.直接使用 blob 上傳
const json = { hello: "world" };
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
const form = new FormData();
form.append('file', blob, '1.json');
axios.post('http://localhost:7787/files', form);
2.使用 File 對象,再進行一次包裝(File 兼容性可能會差一些 https://caniuse.com/#search=File)
const json = { hello: "world" };
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
const file = new File([blob], '1.json');
form.append('file', file);
axios.post('http://localhost:7787/files', form)
ArrayBuffer
對象用來表示通用的、固定長度的原始二進制數據緩沖區。
雖然它用的比較少,但是他是最貼近文件流的方式了。
在瀏覽器中,他每個字節以十進制的方式存在。我提前準備了一張圖片。
const bufferArrary = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,1,3,0,0,0,37,219,86,202,0,0,0,6,80,76,84,69,0,0,255,128,128,128,76,108,191,213,0,0,0,9,112,72,89,115,0,0,14,196,0,0,14,196,1,149,43,14,27,0,0,0,10,73,68,65,84,8,153,99,96,0,0,0,2,0,1,244,113,100,166,0,0,0,0,73,69,78,68,174,66,96,130];
const array = Uint8Array.from(bufferArrary);
const blob = new Blob([array], {type: 'image/png'});
const form = new FormData();
form.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', form)
這里需要注意的是 new Blob([typedArray.buffer], {type: 'xxx'})
,第一個參數是由一個數組包裹。里面是 typedArray
類型的 buffer。
const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAABlBMVEUAAP+AgIBMbL/VAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAACklEQVQImWNgAAAAAgAB9HFkpgAAAABJRU5ErkJggg==';
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const array = Uint8Array.from(byteNumbers);
const blob = new Blob([array], {type: 'image/png'});
const form = new FormData();
form.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', form);
關于 base64 的轉化和原理可以看這兩篇 base64 原理[4] 和
原來瀏覽器原生支持JS Base64編碼解碼[5]
對于瀏覽器端的文件上傳,可以歸結出一個套路,所有東西核心思路就是構造出 File
對象。然后觀察請求 Content-Type
,再看請求體是否有信息缺失。而以上這些二進制數據類型的轉化可以看以下表。
圖片來源 (https://shanyue.tech/post/binary-in-frontend/#%E6%95%B0%E6%8D%AE%E8%BE%93%E5%85%A5[6])
講完了瀏覽器端,現在我們來講服務器端,和瀏覽器不同的是,服務端上傳有兩個難點。
1.瀏覽器沒有原生 formData
,也不會想瀏覽器一樣幫我們轉成二進制形式。
2.服務端沒有可視化的 Network
調試器。
首先我們通過最簡單的示例來進行演示,然后一步一步深入。相信文檔可以查看 https://github.com/request/request#multipartform-data-multipart-form-uploads
// request-error.js
const fs = require('fs');
const path = require('path');
const request = require('request');
const stream = fs.readFileSync(path.join(__dirname, '../1.png'));
request.post({
url: 'http://localhost:7787/files',
formData: {
file: stream,
}
}, (err, res, body) => {
console.log(body);
})
發現報了一個錯誤,正像上面所說,瀏覽器端報錯,可以用NetWork
。那么服務端怎么辦?這個時候我們拿出我們的利器 -- wireshark
我們打開 wireshark
(如果沒有或者不會的可以查看教程 https://blog.csdn.net/u013613428/article/details/53156957)
設置配置 tcp.port == 7787
,這個是我們后端的端口。
運行上述文件 node request-error.js
我們來找到我們發送的這條http
的請求報文。中間那堆亂七八糟的就是我們的文件內容。
POST /files HTTP/1.1
host: localhost:7787
content-type: multipart/form-data; boundary=--------------------------437240798074408070374415
content-length: 305
Connection: close
----------------------------437240798074408070374415
Content-Disposition: form-data; name="file"
Content-Type: application/octet-stream
.PNG
.
...
IHDR.............%.V.....PLTE......Ll..... pHYs..........+.....
IDAT..c`.......qd.....IEND.B`.
----------------------------437240798074408070374415--
可以看到上述報文。發現我們的內容請求頭 Content-Type: application/octet-stream
有錯誤,我們上傳的是圖片請求頭應該是image/png
,并且也少了 filename="1.png"
。
我們來思考一下,我們剛才用的是fs.readFileSync(path.join(__dirname, '../1.png'))
這個函數返回的是 Buffer
,Buffer
是什么樣的呢?就是下面的形式,不會包含任何文件相關的信息,只有二進制流。
<Buffer 01 02>
所以我想到的是,需要指定文件名以及文件格式,幸好 request
也給我們提供了這個選項。
key: {
value: fs.createReadStream('/dev/urandom'),
options: {
filename: 'topsecret.jpg',
contentType: 'image/jpeg'
}
}
可以指定options
,因此正確的代碼應該如下(省略不重要的代碼)
...
request.post({
url: 'http://localhost:7787/files',
formData: {
file: {
value: stream,
options: {
filename: '1.png'
}
},
}
});
我們通過抓包可以進行分析到,文件上傳的要點還是規范,大部分的問題,都可以通過規范模板來進行排查,是否構造出了規范的樣子。
我們再深入一些,來看看 request
的源碼, 他是怎么實現Node
端的數據傳輸的。
打開源碼我們很容易地就可以找到關于 formData 這塊相關的內容 https://github.com/request/request/blob/3.0/request.js#L21
就是利用form-data
,我們先來看看 formData
的方式。
const path = require('path');
const FormData = require('form-data');
const fs = require('fs');
const http = require('http');
const form = new FormData();
form.append('file', fs.readFileSync(path.join(__dirname, '../1.png')), {
filename: '1.png',
contentType: 'image/jpeg',
});
const request = http.request({
method: 'post',
host: 'localhost',
port: '7787',
path: '/files',
headers: form.getHeaders()
});
form.pipe(request);
request.on('response', function(res) {
console.log(res.statusCode);
});
看完formData
,可能感覺這個封裝還是太高層了,于是我打算對照規范手動來構造multipart/form-data
請求方式來進行講解。我們再來回顧一下規范。
Content-type: multipart/form-data, boundary=AaB03x
--AaB03x
content-disposition: form-data; name="field1"
Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain
... contents of file1.txt ...
--AaB03x--
我模擬上方,我用原生 Node
寫出了一個multipart/form-data
請求的方式。
const path = require('path');
const fs = require('fs');
const http = require('http');
// 定義一個分隔符,要確保唯一性
const boundaryKey = '-------------------------461591080941622511336662';
const request = http.request({
method: 'post',
host: 'localhost',
port: '7787',
path: '/files',
headers: {
'Content-Type': 'multipart/form-data; boundary=' + boundaryKey, // 在請求頭上加上分隔符
'Connection': 'keep-alive'
}
});
// 寫入內容頭部
request.write(
`--${boundaryKey}\r\nContent-Disposition: form-data; name="file"; filename="1.png"\r\nContent-Type: image/jpeg\r\n\r\n`
);
// 寫入內容
const fileStream = fs.createReadStream(path.join(__dirname, '../1.png'));
fileStream.pipe(request, { end: false });
fileStream.on('end', function () {
// 寫入尾部
request.end('\r\n--' + boundaryKey + '--' + '\r\n');
});
request.on('response', function(res) {
console.log(res.statusCode);
});
至此,已經實現服務端上傳文件的方式。
由于這兩塊就是和Buffer
的轉化,比較簡單,我就不再重復描述了??梢宰鳛榱艚o大家的作業,感興趣的可以給我這個示例代碼倉庫貢獻這兩個示例。
// base64 to buffer
const b64string = /* whatever */;
const buf = Buffer.from(b64string, 'base64');
// stream to buffer
function streamToBuffer(stream) {
return new Promise((resolve, reject) => {
const buffers = [];
stream.on('error', reject);
stream.on('data', (data) => buffers.push(data))
stream.on('end', () => resolve(Buffer.concat(buffers))
});
}
由于服務端沒有像瀏覽器那樣 formData
的原生對象,因此服務端核心思路為構造出文件上傳的格式(header,filename等),然后寫入 buffer
。然后千萬別忘了用 wireshark
進行驗證。
這一部分是針對 Node
端進行講解,對于那些 koa-body
等用慣了的同學,可能一樣不太清楚整個過程發生了什么?可能唯一比較清楚的是 ctx.request.files
??? 如果ctx.request.files
不存在,就會懵逼了,可能也不太清楚它到底做了什么,文件流又是怎么解析的。
我還是要說到規范...請求端是按照規范來構造請求..那么我們接收端自然是按照規范來解析請求了。
const koaBody = require('koa-body');
app.use(koaBody({ multipart: true }));
我們來看看最常用的 koa-body
,它的使用方式非常簡單,短短幾行,就能讓我們享受到文件上傳的簡單與快樂(其他源碼庫一樣的思路去尋找問題的本源) 可以帶著一個問題去閱讀,為什么用了它就能解析出文件?
尋求問題的本源,我們當然要打開 koa-body
的源碼,koa-body
源碼很少只有211行,https://github.com/dlau/koa-body/blob/v4.1.1/index.js#L125 很容易地發現它其實是用了一個叫做formidable
的庫來解析files
的。并且把解析好的files
對象賦值到了 ctx.req.files
。(所以說大家不要一味死記 ctx.request.files
, 注意查看文檔,因為今天用 koa-body
是 ctx.request.files
明天換個庫可能就是 ctx.request.body
了)
因此看完koa-body
我們得出的結論是,koa-body
的核心方法是formidable
那么讓我們繼續深入,來看看formidable
做了什么,我們首先來看它的目錄結構。
.
├── lib
│ ├── file.js
│ ├── incoming_form.js
│ ├── index.js
│ ├── json_parser.js
│ ├── multipart_parser.js
│ ├── octet_parser.js
│ └── querystring_parser.js
看到這個目錄,我們大致可以梳理出這樣的關系。
index.js
|
incoming_form.js
|
type
?
|
1.json_parser
2.multipart_parser
3.octet_parser
4.querystring_parser
由于源碼分析比較枯燥。因此我只摘錄比較重要的片段。由于我們是分析文件上傳,所以我們只需要關心multipart_parser
這個文件。
https://github.com/node-formidable/formidable/blob/v1.2.1/lib/multipart_parser.js#L72
...
MultipartParser.prototype.write = function(buffer) {
console.log(buffer);
var self = this,
i = 0,
len = buffer.length,
prevIndex = this.index,
index = this.index,
state = this.state,
...
我們將它的 buffer
打印看看.
<Buffer 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 34 36 31 35 39 31 30 38 30 39 34 31 36 32 32 35 31 31 33 33 36 36 36 ... >
144
<Buffer 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 00 00 00 01 00 00 00 01 01 03 00 00 00 25 db 56 ca 00 00 00 06 50 4c 54 45 00 00 ff 80 80 80 4c 6c bf ... >
106
<Buffer 0d 0a 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 34 36 31 35 39 31 30 38 30 39 34 31 36 32 32 35 31 31 33 33 36 ... >
我們來看wireshark
抓到的包
我用紅色進行了分割標記,對應的就是formidable
所分割的片段 ,所以說這個包主要是將大段的 buffer
進行分割,然后循環處理。
這里我還可以補充一下,可能你對以上表非常陌生。左側是二進制流,每1個代表1個字節,1字節=8位,上面的 2d 其實就是16進制的表示形式,用二進制表示就是 0010 1101,右側是ascii 碼用來可視化,但是 assii 分可顯和非可顯示。有部分是無法可視的。比如你所看到文件中有需要小點,就是不可見字符。
你可以對照,ascii表對照表[7]來看。
我來總結一下formidable
對于文件的處理流程。
好了,我們已經知道了文件處理的流程,那么我們自己來寫一個吧。
const fs = require('fs');
const http = require('http');
const querystring = require('querystring');
const server = http.createServer((req, res) => {
if (req.url === "/files" && req.method.toLowerCase() === "post") {
parseFile(req, res)
}
})
function parseFile(req, res) {
req.setEncoding("binary");
let body = "";
let fileName = "";
// 邊界字符
let boundary = req.headers['content-type']
.split('; ')[1]
.replace("boundary=", "")
req.on("data", function(chunk) {
body += chunk;
});
req.on("end", function() {
// 按照分解符切分
const list = body.split(boundary);
let contentType = '';
let fileName = '';
for (let i = 0; i < list.length; i++) {
if (list[i].includes('Content-Disposition')) {
const data = list[i].split('\r\n');
for (let j = 0; j < data.length; j++) {
// 從頭部拆分出名字和類型
if (data[j].includes('Content-Disposition')) {
const info = data[j].split(':')[1].split(';');
fileName = info[info.length - 1].split('=')[1].replace(/"/g, '');
console.log(fileName);
}
if (data[j].includes('Content-Type')) {
contentType = data[j];
console.log(data[j].split(':')[1]);
}
}
}
}
// 去除前面的請求頭
const start = body.toString().indexOf(contentType) + contentType.length + 4; // 有多\r\n\r\n
const startBinary = body.toString().substring(start);
const end = startBinary.indexOf("--" + boundary + "--") - 2; // 前面有多\r\n
// 去除后面的分隔符
const binary = startBinary.substring(0, end);
const bufferData = Buffer.from(binary, "binary");
fs.writeFile(fileName, bufferData, function(err) {
res.end("sucess");
});
;
})
}
server.listen(7787)
相信有了以上的介紹,你不再對文件上傳有所懼怕, 對文件上傳整個過程都會比較清晰了,還不懂。。。。找我。
再次回顧下我們的重點:
請求端出問題,瀏覽器端打開 network
查看格式是否正確(請求頭,請求體), 如果數據不夠詳細,打開wireshark
,對照我們的規范標準,看下格式(請求頭,請求體)。
接收端出問題,情況一就是請求端缺少信息,參考上面請求端出問題的情況,情況二請求體內容錯誤,如果說請求體內容是請求端自己構造的,那么需要檢查請求體是否是正確的二進制流(例如上面的blob構造的時候,我一開始少了一個[],導致內容主體錯誤)。
其實講這么多就兩個字: **規范**[8],所有的生態都是圍繞它而展開的。更多請看我的博客[9]
https://juejin.im/post/5c9f4885f265da308868dad1
https://my.oschina.net/bing309/blog/3132260
https://segmentfault.com/a/1190000020654277
[1]《RFC 1867: Form-based File Upload in HTML》: https://www.ietf.org/rfc/rfc1867.txt
[2]《RFC 1867: Form-based File Upload in HTML》: https://www.ietf.org/rfc/rfc1867.txt
[3]File
: https://developer.mozilla.org/zh-CN/docs/Web/API/File
[4]base64 原理: https://blog.csdn.net/wo541075754/article/details/81734770
[5]原來瀏覽器原生支持JS Base64編碼解碼: https://www.zhangxinxu.com/wordpress/2018/08/js-base64-atob-btoa-encode-decode/
[6]https://shanyue.tech/post/binary-in-frontend/#%E6%95%B0%E6%8D%AE%E8%BE%93%E5%85%A5: https://shanyue.tech/post/binary-in-frontend/#數據輸入
[7]ascii表對照表: http://ascii.911cha.com/
[8]規范: https://www.ietf.org/rfc/rfc1867.txt
[9]我的博客: https://qiufeng.blue/
[10]shark-cleaner: 一個Node Cli 實現的垃圾清理工具(深層清理開發垃圾): https://juejin.im/post/5e78c0785188255e1a15b8a3
[11]Node + NAPI 實現 C++ 擴展 - LRU 淘汰算法: https://juejin.im/post/5e0171ab5188251221769c3c
[12]開發一個Node命令行小玩具全過程--高顏統計工具: https://juejin.im/post/5b7296796fb9a0098165610d
[13]webchat: https://github.com/hua1995116/webchat
[14]shark-cleaner: https://github.com/hua1995116/shark-cleaner
[15]google-translate-open-api: https://github.com/hua1995116/google-translate-open-api
最多閱讀