一、需求

egg.js 的文件上傳個人覺得很一般,內置的 multipart 插件並不怎么好用。

egg-multipart 也是基於 co-busboy 實現的。

egg 官方給的文件上傳的示例地址:

二、CSRF 校驗

egg 文件的上傳需要進行 csrf 校驗,而且這個校驗默認只支持放在 ctx.query._csrf 字段中,很不方便,每次文件上傳都會攜帶。

習慣 laravel 或者某些 PHP 框架的人都知道,直接放在 <input type="hidden" name="_csrf" > 的字段中方便的多。(不過我默認試過放在 input 字段中,是不起作用的,其他方法我暫時沒有嘗試)

至於生成 csrf 的方式,可以在 cookie 中獲取,cookie 中存放了 csrfToken=token,因此是可以通過前端獲取的,這為通過 ajax 實現文件上傳也提供了幫助。

如果在模板文件中使用的話,可以通過 {{ctx.csrf | safe}} 生成 token。

因為 token 必須放在 query._csrf 中,所以 form 的 html 如下:

<form action="/post/add?_csrf={{ctx.csrf | safe}}" method="POST" enctype="multipart/form-data"> </form>

1.jpg

三、通過 form 表單上傳文件

表單 html 不過多描述,默認使用上面的 url 進行表單提交,路由也不具體說明。

文件上傳的時候使用了額外的4個依賴,依賴如下:

依賴 說明
stream-wormhole 將 stream 流消耗掉
await-stream-ready 文件讀寫流 ready 庫,能夠使用 await

await-stream-ready 主要是方便的使用 await 進行文件上傳,而 stream-wormhole 是因為如果上傳失敗出現異常,那會導致瀏覽器響應崩潰,因此需要將 stream 消耗掉。

重點說下 controller 中的代碼:

app/controller/post.js

  // 添加文章 async add(){ await this.ctx.render('post/add'); } // 添加文章操作 async addAction(){ const {ctx} = this; // 獲取 steam const stream = await ctx.getFileStream(); // 生成文件名 const filename = Date.now() + '' + Number.parseInt(Math.random() * 10000) + path.extname(stream.filename); // 寫入路徑 const target = path.join(this.config.baseDir, 'app/public/upload/', filename); const writeStream = fs.createWriteStream(target); try { // 寫入文件 await awaitStreamReady(stream.pipe(writeStream)); } catch (err) { // 必須將上傳的文件流消費掉,要不然瀏覽器響應會卡死 await sendToWormhole(stream); throw err; } ctx.body = stream.fields; }

上面代碼中,通過 await ctx.getFileStream() 獲取到流,stream 是一個 FileStream 對象,有許多有用的屬性,比如下面這些屬性,其中 stream.filename 能獲取文件的原始名稱,上面 controller 代碼中,便是使用 filename 獲取到了文件的后綴。

當表單的 enctype 設置成 multipart/form-data 之后,便不能使用 ctx.request.body 獲取其他字段,現在是個空對象。

要獲取其他字段的名字,需要使用 stream.fields 來獲取其他字段的值。

FileStream { fieldname: 'photo', // 字段名 filename: '1.jpg', // 文件名 encoding: '7bit', // 編碼 transferEncoding: '7bit', mime: 'image/jpeg', // 類型 mimeType: 'image/jpeg', fields: { title: '', description: '', author: '', content: '' } }

上傳結果:

{"title":"","description":"","author":"","content":""}

四、通過 ajax 上傳文件

ajax 文件上傳后端代碼基本不用變動,只是前端代碼這邊構建一個 formData 即可。

下面的代碼是官方示例的給的,因為用 formData 進行 ajax 文件上傳很多示例,不再重復,即使使用其他插件也是無所謂的,本質上 HTTP 請求的 Content-type 還是 multipart/form-data

關鍵點在於 _csrf 的獲取,方法 getCsrf() 是通過 cookie 獲取 token 的方式,當然如果頁面還是在模板中,那我覺得通過一個隱藏的值存放 csrf token,然后再去獲取更加方便。

后端代碼沒什么變動的,本質是一樣的。

$('form').submit(function(e) { e.preventDefault(); var formData = new FormData(); formData.append('name', $('input[type=text]').val()); // Attach file formData.append('image', $('input[type=file]')[0].files[0]); // console.log(formData); $.ajax({ url: '/ajax?_csrf=' + getCsrf(), data: formData, method: 'POST', contentType: false, // NEEDED, DON'T OMIT THIS (requires jQuery 1.6+) processData: false, // NEEDED, DON'T OMIT THIS success: function(result) { console.log(result); }, error: function(responseStr) { alert("error", responseStr); } }); // 通過 cookie 獲取 csrf token function getCsrf() { var keyValue = document.cookie.match('(^|;) ?csrfToken=([^;]*)(;|$)'); return keyValue ? keyValue[2] : null; } });

五、自定義文件上傳目錄

按照我自己的習慣,我在上傳文件的時候,自然是希望文件上傳到 app/public/upload/20180707/xxxx.jpg,但是由於 writeStream 的限制,20180707 必然是需要確定文件目錄存在的。

因此如果使用這種方式上傳,則需要增加幾行代碼用來判斷並且生成文件夾。

為了方便生成日期如 20180707 的目錄,我用了 dayjs 庫來格式化時間。

為了省事我直接用了 mkdirSync()

    // 上傳基礎目錄 const uplaodBasePath = 'app/public/upload/'; // 生成文件名 const filename = Date.now() + '' + Number.parseInt(Math.random() * 10000) + path.extname(stream.filename); // 生成文件夾 const dirName = dayjs(Date.now()).format('YYYYMMDD'); // 判斷文件夾是否存在,不存在則直接創建文件夾 if(! fs.existsSync()) fs.mkdirSync(path.join(this.config.baseDir,uplaodBasePath,dirName)); // 生成寫入路徑 const target = path.join(this.config.baseDir, uplaodBasePath, dirName, filename); // 寫入流 const writeStream = fs.createWriteStream(target);

最終結果:

2.jpg

最終的 controller 代碼如下:

// 添加文章操作 async addAction(){ const {ctx} = this; // 獲取 steam const stream = await ctx.getFileStream(); // 上傳基礎目錄 const uplaodBasePath = 'app/public/upload/'; // 生成文件名 const filename = Date.now() + '' + Number.parseInt(Math.random() * 10000) + path.extname(stream.filename); // 生成文件夾 const dirName = dayjs(Date.now()).format('YYYYMMDD'); if(! fs.existsSync()) fs.mkdirSync(path.join(this.config.baseDir,uplaodBasePath,dirName)); // 生成寫入路徑 const target = path.join(this.config.baseDir, uplaodBasePath, dirName, filename); // 寫入流 const writeStream = fs.createWriteStream(target); try { // 寫入文件 await awaitStreamReady(stream.pipe(writeStream)); } catch (err) { // 必須將上傳的文件流消費掉,要不然瀏覽器響應會卡死 await sendToWormhole(stream); throw err; } ctx.body = stream.fields; }