egg.js 通過 form 和 ajax 兩種方式上傳文件並自定義目錄和文件名
一、需求
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>
三、通過 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);
最終結果:
最終的 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; }