【前端實戰(zhàn)】一文了解文件上傳全過程(1.8w字深度解析,進階必備)
前言
平常在寫業(yè)務的時候常常會用的到的是 GET, POST請求去請求接口,GET 相關(guān)的接口會比較容易基本不會出錯,而對于 POST中常用的 表單提交,JSON提交也比較容易,但是對于文件上傳呢?大家可能對這個步驟會比較害怕,因為可能大家對它并不是怎么熟悉,而瀏覽器Network對它也沒有詳細的進行記錄,因此它成為了我們心中的一根刺,我們老是無法確定,關(guān)于文件上傳到底是我寫的有問題呢?還是后端有問題,當然,我們一般都比較謙虛, 總是會在自己身上找原因,可是往往實事呢?可能就出在后端身上,可能是他接受寫的有問題,導致你換了各種請求庫去嘗試,axios,request,fetch 等等。那么我們?nèi)绾伪苊膺@種情況呢?我們自身要對這一塊夠熟悉,才能不以猜的方式去寫代碼。如果你覺得我以上說的你有同感,那么你閱讀完這篇文章你將收獲自信,你將不會質(zhì)疑自己,不會以猜的方式去寫代碼。
本文比較長可能需要花點時間去看,需要有耐心,我采用自頂向下的方式,所有示例會先展現(xiàn)出你熟悉的方式,再一層層往下, 先從請求端是怎么發(fā)送文件的,再到接收端是怎么解析文件的。
前置知識
什么是 multipart/form-data?
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進行擴展,以允許信息提供者統(tǒng)一表達文件上傳請求,并提供文件上傳響應的MIME兼容表示。
總結(jié)就是原先的規(guī)范不滿足啦,我要擴充規(guī)范了。
文件上傳為什么要用 multipart/form-data?
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:因為此類型不適合用于傳輸大型二進制數(shù)據(jù)或者包含非ASCII字符的數(shù)據(jù)。平常我們使用這個類型都是把表單數(shù)據(jù)使用url編碼后傳送給后端,二進制文件當然沒辦法一起編碼進去了。所以multipart/form-data就誕生了,專門用于有效的傳輸文件。
也許你有疑問?那可以用 application/json嗎?
其實我認為,無論你用什么都可以傳,只不過會要綜合考慮一些因素的話,multipart/form-data更好。例如我們知道了文件是以二進制的形式存在,application/json 是以文本形式進行傳輸,那么某種意義上我們確實可以將文件轉(zhuǎn)成例如文本形式的 Base64 形式。但是呢,你轉(zhuǎn)成這樣的形式,后端也需要按照你這樣傳輸?shù)男问剑鎏厥獾慕馕觥2⑶椅谋驹趥鬏斶^程中是相比二進制效率低的,那么對于我們動輒幾十M幾百M的文件來說是速度是更慢的。
以上為什么文件傳輸要用multipart/form-data 我還可以舉個例子,例如你在中國,你想要去美洲,我們的multipart/form-data相當于是選擇飛機,而application/json相當于高鐵,但是呢?中國和美洲之間沒有高鐵啊,你執(zhí)意要坐高鐵去,你可以花昂貴的代價(后端額外解析你的文本)造高鐵去美洲,但是你有更加廉價的方式坐飛機(使用multipart/form-data)去美洲(去傳輸文件)。你圖啥?(如果你有錢有時間,抱歉,打擾了,老子給你道歉)
multipart/form-data規(guī)范是什么?
摘自 《RFC 1867: Form-based File Upload in HTML》[2] 6.Example
Content-type: multipart/form-data, boundary=AaB03x--AaB03xcontent-disposition: form-data; name="field1"Joe Blow--AaB03xcontent-disposition: form-data; name="pics"; filename="file1.txt"Content-Type: text/plain... contents of file1.txt ...--AaB03x--
可以簡單解釋一些,首先是請求類型,然后是一個 boundary (分割符),這個東西是干啥的呢?其實看名字就知道,分隔符,當時分割作用,因為可能有多文件多字段,每個字段文件之間,我們無法準確地去判斷這個文件哪里到哪里為截止狀態(tài)。因此需要有分隔符來進行劃分。然后再接下來就是聲明內(nèi)容的描述是 form-data 類型,字段名字是啥,如果是文件的話,得知道文件名是啥,還有這個文件的類型是啥,這個也很好理解,我上傳一個文件,我總得告訴后端,我傳的是個啥,是圖片?還是一個txt文本?這些信息肯定得告訴人家,別人才好去進行判斷,后面我們也會講到如果這些沒有聲明的時候,會發(fā)生什么?
好了講完了這些前置知識,我們接下來要進入我們的主題了。面對File, formData,Blob,Base64,ArrayBuffer,到底怎么做?還有文件上傳不僅僅是前端的事。服務端也可以文件上傳(例如我們利用某云,把靜態(tài)資源上傳到 OSS 對象存儲)。服務端和客戶端也有各種類型,Buffer,Stream,Base64....頭禿,怎么搞?不急,就是因為上傳文件不單單是前端的事,所以我將以下上傳文件的一方稱為請求端,接受文件一方稱為接收方。我會以請求端各種上傳方式,接收端是怎么解析我們的文件以及我們最終的殺手锏調(diào)試工具-wireshark來進行講解。以下是講解的大綱,我們先從瀏覽器端上傳文件,再到服務端上傳文件,然后我們再來解析文件是如何被解析的。

請求端
瀏覽端
File
首先我們先寫下最簡單的一個表單提交方式。
我們選擇文件后上傳,發(fā)現(xiàn)后端返回了文件不存在。

不用著急,熟悉的同學可能立馬知道是啥原因了。噓,知道了也聽我慢慢叨叨。
我們打開控制臺,由于表單提交會進行網(wǎng)頁跳轉(zhuǎn),因此我們勾選preserve log 來進行日志追蹤。


我們可以發(fā)現(xiàn)其實 FormData 中 file 字段顯示的是文件名,并沒有將真正的內(nèi)容進行傳輸。再看請求頭。

發(fā)現(xiàn)是請求頭和預期不符,也印證了 application/x-www-form-urlencoded 無法進行文件上傳。
我們加上請求頭,再次請求。

發(fā)現(xiàn)文件上傳成功,簡單的表單上傳就是像以上一樣簡單。但是你得熟記文件上傳的格式以及類型。
FormData
formData 的方式我隨便寫了以下幾種方式。

以上幾種方式都是可以的。但是呢,請求庫這么多,我隨便在 npm 上一搜就有幾百個請求相關(guān)的庫。

因此,掌握請求庫的寫法并不是我們的目標,目標只有一個還是掌握文件上傳的請求頭和請求內(nèi)容。

Blob
Blob 對象表示一個不可變、原始數(shù)據(jù)的類文件對象。Blob 表示的不一定是JavaScript原生格式的數(shù)據(jù)。`File`[3] 接口基于Blob,繼承了 blob 的功能并將其擴展使其支持用戶系統(tǒng)上的文件。
因此如果我們遇到 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
ArrayBuffer 對象用來表示通用的、固定長度的原始二進制數(shù)據(jù)緩沖區(qū)。
雖然它用的比較少,但是他是最貼近文件流的方式了。
在瀏覽器中,他每個字節(jié)以十進制的方式存在。我提前準備了一張圖片。
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'}),第一個參數(shù)是由一個數(shù)組包裹。里面是 typedArray 類型的 buffer。
Base64
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);
關(guān)于 base64 的轉(zhuǎn)化和原理可以看這兩篇 base64 原理[4] 和
原來瀏覽器原生支持JS Base64編碼解碼[5]
小結(jié)
對于瀏覽器端的文件上傳,可以歸結(jié)出一個套路,所有東西核心思路就是構(gòu)造出 File 對象。然后觀察請求 Content-Type,再看請求體是否有信息缺失。而以上這些二進制數(shù)據(jù)類型的轉(zhuǎn)化可以看以下表。

圖片來源 (https://shanyue.tech/post/binary-in-frontend/#%E6%95%B0%E6%8D%AE%E8%BE%93%E5%85%A5[6])
服務端
講完了瀏覽器端,現(xiàn)在我們來講服務器端,和瀏覽器不同的是,服務端上傳有兩個難點。
1.瀏覽器沒有原生 formData,也不會想瀏覽器一樣幫我們轉(zhuǎn)成二進制形式。
2.服務端沒有可視化的 Network 調(diào)試器。
Buffer
Request
首先我們通過最簡單的示例來進行演示,然后一步一步深入。相信文檔可以查看 https://github.com/request/request#multipartform-data-multipart-form-uploads
// request-error.jsconst 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);})

發(fā)現(xiàn)報了一個錯誤,正像上面所說,瀏覽器端報錯,可以用NetWork。那么服務端怎么辦?這個時候我們拿出我們的利器 -- wireshark
我們打開 wireshark (如果沒有或者不會的可以查看教程 https://blog.csdn.net/u013613428/article/details/53156957)
設置配置 tcp.port == 7787,這個是我們后端的端口。

運行上述文件 node request-error.js

我們來找到我們發(fā)送的這條http的請求報文。中間那堆亂七八糟的就是我們的文件內(nèi)容。
POST /files HTTP/1.1host: localhost:7787content-type: multipart/form-data; boundary=--------------------------437240798074408070374415content-length: 305Connection: close----------------------------437240798074408070374415Content-Disposition: form-data; name="file"Content-Type: application/octet-stream.PNG....IHDR.............%.V.....PLTE......Ll..... pHYs..........+.....IDAT..c`.......qd.....IEND.B`.----------------------------437240798074408070374415--
可以看到上述報文。發(fā)現(xiàn)我們的內(nèi)容請求頭 Content-Type: application/octet-stream有錯誤,我們上傳的是圖片請求頭應該是image/png,并且也少了 filename="1.png"。
我們來思考一下,我們剛才用的是fs.readFileSync(path.join(__dirname, '../1.png')) 這個函數(shù)返回的是 Buffer,Buffer是什么樣的呢?就是下面的形式,不會包含任何文件相關(guān)的信息,只有二進制流。
所以我想到的是,需要指定文件名以及文件格式,幸好 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'}},}});
我們通過抓包可以進行分析到,文件上傳的要點還是規(guī)范,大部分的問題,都可以通過規(guī)范模板來進行排查,是否構(gòu)造出了規(guī)范的樣子。
Form-data
我們再深入一些,來看看 request 的源碼, 他是怎么實現(xiàn)Node端的數(shù)據(jù)傳輸?shù)摹?/p>
打開源碼我們很容易地就可以找到關(guān)于 formData 這塊相關(guān)的內(nèi)容 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);});
原生 Node
看完formData,可能感覺這個封裝還是太高層了,于是我打算對照規(guī)范手動來構(gòu)造multipart/form-data請求方式來進行講解。我們再來回顧一下規(guī)范。
Content-type: multipart/form-data, boundary=AaB03x--AaB03xcontent-disposition: form-data; name="field1"Joe Blow--AaB03xcontent-disposition: form-data; name="pics"; filename="file1.txt"Content-Type: text/plain... contents of file1.txt ...--AaB03x--
我模擬上方,我用原生 Node 寫出了一個multipart/form-data 請求的方式。
主要分為4個部分
構(gòu)造請求header
構(gòu)造內(nèi)容header
寫入內(nèi)容
寫入結(jié)束分隔符
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'}});// 寫入內(nèi)容頭部request.write(`--${boundaryKey}\r\nContent-Disposition: form-data; name="file"; filename="1.png"\r\nContent-Type: image/jpeg\r\n\r\n`);// 寫入內(nèi)容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);});
至此,已經(jīng)實現(xiàn)服務端上傳文件的方式。
Stream、Base64
由于這兩塊就是和Buffer的轉(zhuǎn)化,比較簡單,我就不再重復描述了。可以作為留給大家的作業(yè),感興趣的可以給我這個示例代碼倉庫貢獻這兩個示例。
// base64 to bufferconst b64string = /* whatever */;const buf = Buffer.from(b64string, 'base64');
// stream to bufferfunction 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))});}
小結(jié)
由于服務端沒有像瀏覽器那樣 formData 的原生對象,因此服務端核心思路為構(gòu)造出文件上傳的格式(header,filename等),然后寫入 buffer 。然后千萬別忘了用 wireshark進行驗證。
接收端
這一部分是針對 Node 端進行講解,對于那些 koa-body 等用慣了的同學,可能一樣不太清楚整個過程發(fā)生了什么?可能唯一比較清楚的是 ctx.request.files ??? 如果ctx.request.files 不存在,就會懵逼了,可能也不太清楚它到底做了什么,文件流又是怎么解析的。
我還是要說到規(guī)范...請求端是按照規(guī)范來構(gòu)造請求..那么我們接收端自然是按照規(guī)范來解析請求了。
Koa-body
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 很容易地發(fā)現(xiàn)它其實是用了一個叫做formidable的庫來解析files 的。并且把解析好的files 對象賦值到了 ctx.req.files。(所以說大家不要一味死記 ctx.request.files, 注意查看文檔,因為今天用 koa-body是 ctx.request.files 明天換個庫可能就是 ctx.request.body 了)
因此看完koa-body我們得出的結(jié)論是,koa-body的核心方法是formidable
Formidable
那么讓我們繼續(xù)深入,來看看formidable做了什么,我們首先來看它的目錄結(jié)構(gòu)。
.├── lib│?? ├── file.js│?? ├── incoming_form.js│?? ├── index.js│?? ├── json_parser.js│?? ├── multipart_parser.js│?? ├── octet_parser.js│?? └── querystring_parser.js
看到這個目錄,我們大致可以梳理出這樣的關(guān)系。
index.js|incoming_form.js|type?|1.json_parser2.multipart_parser3.octet_parser4.querystring_parser
由于源碼分析比較枯燥。因此我只摘錄比較重要的片段。由于我們是分析文件上傳,所以我們只需要關(guān)心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 打印看看.
144106
我們來看wireshark 抓到的包

我用紅色進行了分割標記,對應的就是formidable所分割的片段 ,所以說這個包主要是將大段的 buffer 進行分割,然后循環(huán)處理。
這里我還可以補充一下,可能你對以上表非常陌生。左側(cè)是二進制流,每1個代表1個字節(jié),1字節(jié)=8位,上面的 2d 其實就是16進制的表示形式,用二進制表示就是 0010 1101,右側(cè)是ascii 碼用來可視化,但是 assii 分可顯和非可顯示。有部分是無法可視的。比如你所看到文件中有需要小點,就是不可見字符。
你可以對照,ascii表對照表[7]來看。
我來總結(jié)一下formidable對于文件的處理流程。

原生 Node
好了,我們已經(jīng)知道了文件處理的流程,那么我們自己來寫一個吧。
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\nconst 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)
總結(jié)
相信有了以上的介紹,你不再對文件上傳有所懼怕, 對文件上傳整個過程都會比較清晰了,還不懂。。。。找我。
再次回顧下我們的重點:
請求端出問題,瀏覽器端打開 network 查看格式是否正確(請求頭,請求體), 如果數(shù)據(jù)不夠詳細,打開wireshark,對照我們的規(guī)范標準,看下格式(請求頭,請求體)。
接收端出問題,情況一就是請求端缺少信息,參考上面請求端出問題的情況,情況二請求體內(nèi)容錯誤,如果說請求體內(nèi)容是請求端自己構(gòu)造的,那么需要檢查請求體是否是正確的二進制流(例如上面的blob構(gòu)造的時候,我一開始少了一個[],導致內(nèi)容主體錯誤)。
其實講這么多就兩個字: **規(guī)范**[8],所有的生態(tài)都是圍繞它而展開的。更多請看我的博客[9]。
相關(guān)閱讀
Node ?Napi 實現(xiàn) C++ ?擴展 - LRU 淘汰算法開發(fā)一個Node命令行小玩具全過程--高顏統(tǒng)計工具
開發(fā)一個Node命令行小玩具全過程--高顏統(tǒng)計工具
歡迎關(guān)注「前端雜貨鋪」,一個有溫度且致力于前端分享的雜貨鋪
關(guān)注回復「加群」,可加入雜貨鋪一起交流學習成長
參考
https://juejin.im/post/5c9f4885f265da308868dad1
https://my.oschina.net/bing309/blog/3132260
https://segmentfault.com/a/1190000020654277
參考資料
《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
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/#數(shù)據(jù)輸入
[7]ascii表對照表: http://ascii.911cha.com/
[8]規(guī)范: https://www.ietf.org/rfc/rfc1867.txt
[9]我的博客: https://qiufeng.blue/
[10]shark-cleaner: 一個Node Cli 實現(xiàn)的垃圾清理工具(深層清理開發(fā)垃圾): https://juejin.im/post/5e78c0785188255e1a15b8a3
[11]Node + NAPI 實現(xiàn) C++ 擴展 - LRU 淘汰算法: https://juejin.im/post/5e0171ab5188251221769c3c
[12]開發(fā)一個Node命令行小玩具全過程--高顏統(tǒng)計工具: 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
