การอัพโหลดไฟล์ด้วย Multer ใน Express สำหรับ ระบบสมาชิก

เขียนเมื่อ 4 ปีก่อน โดย Ninenik Narkdee
expressjs file upload multer nodejs อัพโหลดไฟล์

คำสั่ง การ กำหนด รูปแบบ ตัวอย่าง เทคนิค ลูกเล่น การประยุกต์ การใช้งาน เกี่ยวกับ expressjs file upload multer nodejs อัพโหลดไฟล์

ดูแล้ว 7,800 ครั้ง


สำหรับเนื้อหาต่อไปนี้ จะมาดูเกี่ยวกับการอัพโหลดไฟล์
ในบทความนี้ จะเป็นการอัพโหลดไฟล์รูปภาพในระบบสมาชิก
ซึ่งเป็นบทความต่อเนื้อง 
    เพื่อจำลองการใช้งานการอัพโหลดไฟล์รูปภาพ เราจะทำการเพิ่มหน้า
โพรไฟล์ หรือก็คือหน้าแก้ไขข้อมูลส่วนตัวในระบบสมาชิก โดยความสามารถ
เพิ่มเติม คือ เราต้องการให้สมาชิก สามารถที่อัพโหลดไฟล์รูปภาพ เพื่อกำหนด
เป็น avatar หรือรูปโพรไฟล์ได้
    ในการใช้งานการอัพโหลดไฟล์ใน Express เราจะใช้ Multer ซึ่งเป็น Middleware ฟังก์ชั่น
โมดูลหลักที่ใช้ในการทำการอัพโหลดไฟล์ โดย Multer จะทำงานในฟอร์มที่มีการกำหนด
enctype เท่ากับ "multipart/form-data" เท่านั้น เราจะมาทำการติดตั้ง เรียนรู้การใช้งาน 
และจบ ด้วยการนำมาประยุกต์ใช้งานในระบบสมาชิก ตามลำดับ
 
 
 

การติดตั้ง Multer 

    โดยให้ทำการติดตั้ง Multer ด้วยคำสั่ง
 
npm install multer --save
 
 

การใช้งาน Multer

    หลังจากทำการติดตั้ง Multer เรียบร้อยแล้ว ให้เราเรียกใช้งาน Multer Module ใน routes ไฟล์ที่ต้องการใช้งาน
การอัพโหลดไฟล์ โดยเพิ่ม
const multer = require('multer') // ใช้งาน Multer Module
    ต่อด้วยการกำหนดการตั้งค่าต่างๆ เช่น
// จะทำการอัพโหลดไปยังโฟลเดอร์ uploads ที่ root (เราต้องสร้างโฟลเดอร์ นี้ก่อนเรียกใช้งาน)
// การกำหนดโฟลเดอร์ จะอ้างอิงตำแหน่ง จาก root นั่นคือ เราไม่ต้องกำหนด path แบบ relative 
// เช่น ไม่ต้องกำหนดเป็น '../uploads/

let upload = multer({ dest: 'uploads/' })
    และสุดท้ายส่วนของการนำไปใช้งาน เช่น
app.post(upload.single('avatar'), (req, res, next) => {})
    หลักๆ จะมี 3 ขั้นตอนก็คือ 1. เรียกใช้ Module  2. ตั่งค่าต่างๆ และ 3.ใช้งาน Middleware
 
    ที่เราจะต้องมาลงรายละเอียด ก็คือในขั้นตอนการตั้งค่าต่างๆ และขั้นตอนการใช้งาน  เช่น การตั้งค่าก็อาจจะเป็น
การกำหนดโฟลเดอร์ที่จะเก็บไฟล์ การจัดการชื่อไฟล์ที่จะบันทึก การจัดการประเภทหรือชนิดของไฟล์ที่อนุญาตให้อัพ
โหลดได้ การจำกัดขนาดของไฟล์สูงสุดที่สามารถอัพโหลดได้ เป็นต้น 
    ส่วนการใช้งานก็จะมีเรื่อง การใช้งานกรณีอัพโหลดแค่ไฟล์เดียว การใช้งานกรณีอัพโหลดทีละหลายๆ ไฟล์พร้อมกัน
เช่นการอัพโหลดรูปภาพ gallery อัพทีละหลายรูป  เป็นต้น  การใช้งานกรณีอัพโหลดทีละหลายๆ ไฟล์ และมีหลายฟิลด์ เช่น
อัพโหลดรูปโพรไฟล์ กับอัพโหลดรูป Cover เป็นต้น  หรือแม้แต่ การใช้งานกรณีไม่ต้องการใช้งานเกี่ยวกับการอัพโหลด 
จะเป็นลักษณะคล้ายกับการไปใช้งานในรูปแบบการส่งค่าฟอร์มทั่วไป ที่ไมีมีการอัพโหลดไฟล์ 
    เราจะข้ามในขั้นตอนการตั้งค่าไปก่อน จะอธิบายในขั้นตอนการใช้งาน middleware เพื่อให้เห็นภาพ
 
 

    การใช้งาน Multer Middleware

    1. การอัพโหลดแค่ไฟล์เดียวในฟอร์ม จะใช้ในรูปแบบ
upload.single('ชื่อฟิลด์ input file')
    ลักษณะของฟอร์ม
<form action="/profile" method="post" enctype="multipart/form-data">
  <input type="file" name="avatar" />
</form>
    การใช้งานก็จะเป็น
app.post(upload.single('avatar'), (req, res, next) => {})

// เรียกใช้ไฟล์จากตัวแปร req.file 
 
    2. การอัพโหลดทีละหลายๆ ไฟล์ พร้อมกัน
upload.array('ชื่อฟิลด์ input file', [จำนวนไฟล์อัพโหลด สูงสุดไม่เกิน])
    ลักษณะของฟอร์ม
<form action="/gallery" method="post" enctype="multipart/form-data">
  <input type="file" name="photos" multiple />
</form>
    การใช้งานก็จะเป็น
// จำนวนไฟล์สูงสุด จะกำหนดหรือไม่ก็ได้ 
// จำนวนไฟล์สูงสุดไม่เกิน 4 หมายความว่า ในฟอร์มด้านบน สามารถเลือกไฟล์ได้สูงสุดไม่เกิน 4
// อาจจะไม่อัพโหลด หรือ อัพโหลดแค่ 2 ไฟล์ได้ แต่จะเลือกอัพโหลดพร้อมกัน 5 ไฟล์ไม่ได้

app.post(upload.array('photos', 4), (req, res, next) => {})

// เรียกใช้ไฟล์จากตัวแปร req.files  เช่น
// req.files['photos'][0]...req.files['photos'][3]
 
    3. การอัพโหลดพร้อมกันทีละหลายๆ ไฟล์ และมี input file หลายฟิลด์ 
upload.fields([
	{ name: 'ชื่อฟิลด์ input file', maxCount: จำนวนสูงสุด }, 
	{ name: 'ชื่อฟิลด์ input file', maxCount: จำนวนสูงสุด },
])
    ลักษณะของฟอร์ม
<form action="/home" method="post" enctype="multipart/form-data">
    <input type="file" name="photos" multiple />
    <input type="file" name="cover" multiple /> 
</form>
    การใช้งานก็จะเป็น
app.post(upload.fields([
	{ name: 'photos', maxCount: 1 }, 
	{ name: 'cover', maxCount: 2 }
]), (req, res, next) => {})

// เรียกใช้ไฟล์จากตัวแปร req.files  เช่น
// req.files['photos'][0]
// req.files['cover'][0] , req.files['photos'][1]
 
    4. การอัพโหลดไฟล์ใดๆ ก็ได้ หาก input file ใดๆ ส่งค่ามา ก็จะทำการอัพโหลด 
upload.any()
    ลักษณะของฟอร์ม
<form action="/home" method="post" enctype="multipart/form-data">
    <input type="file" name="photos" multiple />
    <input type="file" name="cover" /> 
</form>
    การใช้งาน
app.post(upload.any(), (req, res, next) => {})

// เรียกใช้ไฟล์จากตัวแปร req.files  เช่น
// req.files['photos'][0]...req.files['photos'][n]
// req.files['cover'][0]
 
    5. การไม่อนุญาตให้อัพโหลดไฟล์ใดๆ 
upload.none()
    ลักษณะฟอร์มใดๆ ก็ตาม ที่มี input file และใช้งาน multipart/form-data
 
    การใช้งาน
app.post(upload.none(), (req, res, next) => {})

// ใช้ได้เฉพาะข้อมูลจากฟอร์ม ที่ไม่ใช่ input file ผ่าน req.body
// ห้ามอัพโหลดไฟล์ ไม่สามารถใช้งาน req.file หรือ req.files ได้
    รูปแบบที่ 2 กรณีกำหนดจำนวนสูงสุด และรูปแบบที่ 3 ทั้งสองรูปแบบนี้ ถ้ามีการเลือกไฟล์ไม่ตรงตามเงื่อนไข ของ
จำนวนไฟล์สูงสุดที่เลือก จะไม่สามารถทำการอัพโหลดไฟล์ได้ และเกิด error
    รูปแบบที่ 4 จะยืดหยุ่นที่สุด เหมาะกรณีเป็นการอัพโหลดรูปที่ไม่ได้กำหนดเงื่อนไขใดๆ (เงื่อนไขเรื่องจำนวนและฟิลด์)
    รูปแบบที่ 5 จะเกิด error หากเลือกอัพโหลดไฟล์ นั่นคือห้ามอัพโหลดไฟล์ ให้ใช้เฉพาะส่งค่าฟอร์มเท่านั้น
 
 

    File Information ข้อมูลไฟล์

    ค่า property ของไฟล์ req.file และ req.files จะประกอบไปด้วยค่าต่างๆ ดังนี้ 
 
{ 
	fieldname: 'cover', // ชื่อฟิลด์ input file
	originalname: 'water.jpg', // ชื่อไฟล์ต้นฉบับที่อัพโหลด
	encoding: '7bit', // ประเภท encoding ของไฟล์
	mimetype: 'image/jpeg', // Mime type ของไฟล์ ตัวระบุประเภทไฟล์ เช่น ไฟล์รูป
	destination: 'uploads/avatar/', // โฟลเดอร์ที่เก็บไฟล์ที่อัพโหลด
	filename: 'c544023062d5ab27d60cb0e6c0ecf5e3', // ชื่อไฟล์ที่บันทึก 
	path: 'uploads\\avatar\\c544023062d5ab27d60cb0e6c0ecf5e3', // path ไฟล์ที่บันทึก
	size: 2538505 // ขนาดไฟล์ หน่วย bytes
}
    นอกจาก property ทั้ง 8 ข้างต้น แล้วยังมีอีก 1 property คือ buffer จะมีค่านี้ กรณีเก็บไฟล์ไว้ใน MemoryStorage
ซึ่งหากมี buffer จะไม่มีค่า destination, filename และ path ที่เป็นค่าของการจัดเก็บบน DiskStorage
    โดยทั่วไปแล้ว การใช้งานจะใช้ในการจัดเก็บไฟล์แบบ DiskStorage โดยกำหนด options ที่ชื่อ "dest" เป็นโฟลเดอร์
ที่้ต้องการเก็บไฟล์ที่อัพโหลด เราจะดูการกำหนด options ในหัวข้อถัดไปด้านล่าง  นอกจากนั้น ถ้าสังเกตที่ชื่อไฟล์ที่บันทึก
Multer จะทำการเปลี่ยนชื่อไฟล์เพื่อป้องกันชื่อไฟล์ซ้ำกัน โดยเป็นลักษณะ hash และไม่มีนามสกุลไฟล์ด้านหลัง อย่างไร
ก็ตาม เราสามารถแก้ไขชื่อไฟล์หากต้องการผ่านการใช้งานฟังก์ชั่นการแก้ไขชื่อไฟล์
 
    คำเตือน: การใช้งานสำหรับการอัพโหลดไฟล์ ไม่ควรกำหนดแบบ global ให้กำหนดและใช้งานเฉพาะใน routes path
ที่ต้องการใช้งานการอัพโหลดไฟล์ เพื่อป้องกันในเรื่องความปลอดภัย
 
 

    การกำหนด Multer Options

    รูปแบบการกำหนด Multer options จะเป็นดังนี้
multer(opts)

// ตัวอย่างเช่น 
// let upload = multer({ dest: 'uploads/' })
    เราสามารถกำหนด options ต่างๆ ดังนี้
  • dest | storage กำหนดที่สำหรับเก็บไฟล์ที่อัพโหลด
  • fileFilter ฟังก์ชันสำหรับตรวจสอบ และกำหนดประเภทไฟล์ที่อนุญาต
  • limits กำหนดข้อจำกัดต่างๆ ของไฟล์ที่อัพโหลด
  • preservePath ให้คง path เต็มไว้ แทนการใช้งาน แค่ชื่อไฟล์
    หลักๆ จะกำหนดแค่ 3 ค่าแรก ส่วนค่าที่ 4 ลองทดสอบแล้ว ไม่เห็นผลหรือรูปแบบการใช้งานที่ชัดเจน ตัวอย่าง
การกำหนดค่าทั้ง 3 เบื้องต้นพร้อมกัน
let upload = multer({ 
    dest: 'uploads/avatar/', // กำหนดโฟลเดอร์ที่จะเก็บไฟล์
    fileFilter:(req, file, cb)=>{
		// ถ้าไม่ใช่ไฟล์รูปภาพ
        if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) { // ตรวจสอบชนิดไฟล์
            return cb(new Error('เฉพาะไฟล์รูปภาพเท่านั้น!'), false)
        }
        cb(null, true) // ถ้าเป็นไฟล์รูปภาพ ผ่านเงื่อนไขการตรวจสอบประเภทไฟล์
    },
    limits:{
        fileSize:2000000 // กำหนดขนาดไฟล์ไม่เกิน 2 MB = 2000000 bytes
    }
})
 
    สำหรับการกำหนด dest กับ storage นั่นจะแตกต่างตรง ถ้าเรากำหนด dest เราจะแค่กำหนดโฟลเดอร์ที่ต้องการ
บันทึกไฟล์ที่อัพโหลดไว้ ซึ่งอาจจะง่ายและสะดวก แต่เรา จะไม่สามารถแก้ไขชื่อไฟล์ได้ จะเป็นชื่อไฟล์ที่ Multer 
สร้างให้และไม่มีนามกุลต่อท้าย เหมือนตัวอย่างข้อมูลไฟล์ด้านบน หากเราต้องการเปลี่ยนชื่อไฟล์หรือใช้ชื่อไฟล์แบบ
กำหนดเอง ให้เราเปลี่ยนมาใช้เป็น storage แทนดังนี้
let upload = multer({ 
    storage:multer.diskStorage({
        destination: function (req, file, cb) {
			// ใช้งาน path module กำหนดโฟลเดอร์ที่จะบันทึกไฟล์
            cb(null, path.join(__dirname,'..','uploads/avatar'))
        },
        filename: function (req, file, cb) {
			// เปลี่ยนชื่อไฟล์ ในที่นี้ใช้เวลา timestamp ต่อด้วยชือ่ไฟล์เดิม
			// เช่นไฟล์เดิมเป็น bird.png ก็จะได้เป็น  1558631524415-bird.png
            cb(null, Date.now() + '-' + file.originalname)
        }
    }),
    fileFilter:(req, file, cb)=>{
		// ถ้าไม่ใช่ไฟล์รูปภาพ
        if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) { // ตรวจสอบชนิดไฟล์
            return cb(new Error('เฉพาะไฟล์รูปภาพเท่านั้น!'), false)
        }
        cb(null, true) // ถ้าเป็นไฟล์รูปภาพ ผ่านเงื่อนไขการตรวจสอบประเภทไฟล์
    },
    limits:{
        fileSize:2000000 // กำหนดขนาดไฟล์ไม่เกิน 2 MB = 2000000 bytes
    }
})
 
    การกำหนด path โฟลเดอร์ที่จะบันทึกไฟล์นั้นจากยุ่งยากกว่าการกำหนดกรณีใช้งาน dest ในที่นี้เราใช้งาน path
module เข้ามาใช้ หากไฟล์ routes อัพโหลดนี้อยู่ในโฟลเดอร์ routes โฟลเดอร์เก็บไฟล์ก็จะอยู่ที่ 
path:"../uploads/avatar"  ดังนั้นเราใช้ path module เพื่อสร้าง path สำหรับบันทึกไฟล์ เป็น
path.join(__dirname,'..','uploads/avatar')
นั่นคือถอยจาก path ปัจจุบัน (__dirname) ไป 1 ขั้นแล้วกลับไปยังโฟลเดอร์ "uploads/avatar" เป็นต้น จะอธิบาย step 
ให้เห็นภาพดังนี้
 
step 1 = __dirname จะได้ C:\projects\expressjs\routes
step 2 = .. จะได้ C:\projects\expressjs\
step 3 = uploads/avatar จะได้ C:\projects\expressjs\uploads/avatar
    ในการกำหนดชื่อไฟล์ใหม่ จากเดิม Multer กำหนดให้ แต่เราสามารถใช้ฟังก์ชั่น filename กำหนดค่าตามต้องการได้
เช่น ใช้เป็นชื่อไฟล์จากค่าเดิม ก็จะเป็น
cb(null, file.originalname) // ใช้ชื่อไฟล์ตามต้นฉบับ ซื่ออาจซ้ำกัน หากซ้ำกัน จะทับไฟล์เดิม
    หรือกรณีใช้เป็น timestamp และมีนามสกุลไฟล์ เช่น 1558632811925.jpg
let extArr = file.originalname.split('.')
let ext = extArr[extArr.length-1]
cb(null, Date.now() + '.' + ext)
    สำหรับการกำหนด fileFilter เพื่อกรองหรือกำหนดเงื่อนไขของไฟล์ที่จะอัพโหลด อย่างในตัวอย่างด้านบน เรา
ต้องการกำหนดให้อัพโหลดได้เฉพาะไฟล์รูป โดยใช้งานฟ้งก์ชั่นตรวจสอบ mine type ของไฟล์ว่าเป็นประเภท
ไฟล์รูปภาพหรือไม่ หากไม่ใช้ไฟล์รูปภาพ ก็ใช้ cb (callback ฟังก์ชั่น) ส่ง error กลับออกมา แต่ถ้าผ่านเงื่อนไขก็
ให้ส่งค่า true กลับออกมา
fileFilter:(req, file, cb)=>{
	// ถ้าไม่ใช่ไฟล์รูปภาพ
	if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) { // ตรวจสอบชนิดไฟล์
		return cb(new Error('เฉพาะไฟล์รูปภาพเท่านั้น!'), false)
	}
	cb(null, true) // ถ้าเป็นไฟล์รูปภาพ ผ่านเงื่อนไขการตรวจสอบประเภทไฟล์
}
    เราสามารถประยุกต์หรือปรับฟังก์ชั่นตามต้องการ กรณีเงื่อนไขเป็นอื่น
 
    ส่วนสุดท้ายจะเป็นส่วนของการกำหนด limits ซึ่งเป็น object ที่มี property เพิ่มเติมให้เรากำหนดข้อจำกัดของการไฟล์
ที่จะทำการอัพโหลด ยกตัวอย่างเช่น ขนาดไฟล์ต้องไม่เกิน 2 MB ตามตัวอย่างด้านบน นอกจากนั้น ยังมีค่าต่างๆ ที่เราสามารถ
เลือกนำมากำหนดได้ โดยกำหนดเป้นตัวเลข ดังนี้
 
  • fieldNameSize ความยาวชื่อฟิลด์ ค่าเริ่มต้น ไม่เเกิน 100 bytes (ประมาณ 100 ตัวอักษร)
  • fieldSize ขนาดค่าของฟิลด์ สูงสุด ค่าเริ่มต้น 1MB
  • fields จำนวนฟิลด์ที่ไม่ใช่ input file ค่าเริ่มต้นคือไม่จำกัด
  • fileSize ขนาดไฟล์ที่จะอัพโหลดต่อ 1 ไฟล์ (หน่วย bytes) ค่าเริ่มต้นคือไม่จำกัด
  • files จำนวนฟิลด์ input file สูงสุด ค่าเริ่มต้นคือไม่จำกัด
  • parts จำนวน path สูงสุด (fields + files) ค่าเริ่มต้นคือไม่จำกัด
  • headerPairs จำนวนสูงสุดของ header ค่า key=>value ที่จับคู่กัน ค่าเริ่มต้น 2000
 
    หากต้องการกำหนดค่าใดๆ ก็นำ property ด้านบนกำหนดค่าเพิ่มเข้าไป เช่น
limits:{
	fileSize:20000000,
	fieldSize:2 // 2MB
}
    เงื่อนไข หรือข้อจำกัดต่างๆ ที่เรากำหนดให้กับการใช้งานการอัพโหลดไฟล์ หากไม่ผ่านเงื่อนไขที่กำหนด ก็จะเกิด error
ขึ้น ซึ่งเราอาจจำเป็นต้องใช้หรืออาจต้องการจัดการกับ error เหล่านั้น
 
 
 

การจัดการกับ Error ของไฟล์อัพโหลด

    ปกติเรามีระบบจัดการ error หลักใน Express อยู่แล้ว อย่างไรก็ตาม เราอาจจำเป็นต้องการใช้งานหรืออาจจำเป็นต้องการ
รูปแบบ error เฉพาะสำหรับการอัพโหลดไฟล์ เช่น หากไม่ผ่านเงื่อนไขการอัพโหลดไฟล์ เราอาจจะต้องการแสดง error 
แจ้งเตือนในหน้าอัพโหลดไฟล์นั้นๆ แทนที่จะลิ้งไปยังหน้า error ปกติ ก็สามารถทำได้ดังนี้
    เดิม เราเรียกใช้งานในลักษณะ นี้
let upload = multer({ dest: 'uploads/avatar/' })

app.post('/profile', upload.single('avatar'), function (req, res) { 
    //  
})
    ก็เปลี่ยนมาใช้เป็น
let upload = multer({ dest: 'uploads/avatar/' }).single('avatar')

app.post('/profile', function (req, res) {
	upload(req, res, function (err) {
		if (err instanceof multer.MulterError) {
		  // ไม่ผ่านเงื่อนไขการอัพโหลดไฟล์
		} else if (err) {
		  // ไม่ผ่านเงื่อนไขอื่นๆ 
		}
		
		// ผ่านเงื่อนไข อัพโหลดไฟล์ได้ปกติ
	})
})
 
    จะได้ดูตัวอย่างการประยุกต์ในการใช้งานร่วมกับระบบสมาชิก
 
 
 

การประยุกต์ Multer กับระบบสมาชิก

    เราได้แนวทางการใช้งานหลายๆ ส่วนไปแล้ว ต่อไปจะมาประยุกต์ใช้งานกับระบบสมาชิก สิ่งที่เราต้องการคือ 
ต้องการให้สมาชิก มีหน้าสำหรับแก้ไขข้อมูลส่วนตัวอย่างง่าย ในที่นี้จะแก้ไขแค่ชื่อ และในการแก้ไขข้อมูล
เราให้สมาชิกสามารถเลือกที่จะอัพโหลดรูป avatar หรือรูปโปรไฟล์ได้ โดยจะทำการอัพโหลดไปเก็บไว้ที่โฟลเดอร์
"uploads/avatar" และจะเก็บชื่อไฟล์บันทึกลงในฐานข้อมูล MongoDB ใน user  นั้นๆ ในฟิลด์ชื่อ avatar สิ่งที่เราต้อง
ทำมีดังนี้
    - สร้างโฟลเดอร์ "uploads/avatar"
    - เพิ่มลิ้งค์เมนูไปยังหน้า profile ในไฟล์ nav.ejs [views/partials/nav.ejs]
    - สร้าง validator สำหรับหน้าโปรไฟล์ ในไฟล์ users.js [validator/users.js]
    - เพิ่มฟังก์ชั่นการแก้ไขข้อมูลในใน Users Model ไฟล์ users.js [models/users.js]
    - สร้าง template ไฟล์ profile.ejs [views/pages/profile.ejs]
    - สร้างไฟล์ profile.js [routes/profile.js]
    - แสดงรูปโปรไฟล์ในหน้าสมาชิก dashboard.js [routes/dashboard.js]
 
    ก่อนจะลงไปในแต่ละส่วน ขออธิบายถึงปัญหาที่จะเกิดขึ้น เมื่อเราใช้งาน multipart/form-data 
ปกติเราใช้งานฟอร์มทั่วไป หากไม่มีการอัพโหลดไฟล์ จะเป็นส่งข้อมูลโดยใช้รูปแบบ 
application/x-www-form-urlencoded  ข้อมูลจะถูกส่งไปในรูปแบบ Var1=Value1&Var2=Value2
เราสามารถอ้างอิงฟิลด์ข้อมูลจากฟอร์มรูปแบบนี้ผ่าน req.body (request body) ได้เลย
    แต่เมื่อเรามีการใช้งานการอัพโหลดไฟล์ เราต้องกำหนด enctype="multipart/form-data" เข้าไปใน <form>
เพื่อใช้งานในรูปแบบ multipart/form-data โดยข้อมูลจะถูกส่งผ่าน Content-Type Header ในรูปแบบ
 
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryvnP2DBb8vx7JcXlW
 
    เมื่อเราไม่สามารถอ้างอิงค่าข้อมูลต่างๆ ผ่าน req.body ได้โดยตรง ปัญหาก็คือ เราจะไม่สามารถใช้งาน Middleware
ฟังก์ชั่นที่ป้องกัน CSRF ในรูปแบบเดิมได้ ซึ่งเดิม เราจะกำหนดไว้ใน input hidden แต่กรณีมีการอัพโหลดไฟล์ เราจะ
กำหนดไว้ใน URL query string แทน นั่นคือจากเดิม ที่เราตรวจสอบจากค่า req.body._csrf ก็จะเปลี่ยนมาใช้การตรวจสอบ
จากค่า req.query._csrf แทน
    อีกส่วนที่มีปัญหา คือการใช้งาน Validation หรือการตรวจสอบความถูกต้องของข้อมูลฟอร์ม โดยใช้ Joi Validation
อย่างที่บอกว่า เราไม่สามารถใช้งาน req.body ได้โดยตรง และเนื่องจากค่านี้เป็นค่าที่จะต้องถูกนำไปใช้งานในการตรวจ
สอบความถูกต้องของข้อมูลใน Joi ทำให้เรา ต้องเปลี่ยนรูปแบบใช้งานใหม่ โดยไม่ได้แทรกเข้าไปใน middleware ฟังก์ชั่น
แต่เราจะสร้างฟังก์ชั่นแยกเฉพาะมา และใช้งาน หลังจากข้อมูลถูกแปลงให้มีค่า req.body ด้วย Multer module แล้ว หรือก็คือ
เราจะตรวจสอบข้อมูลฟอร์มหลังจากจัดการเกี่ยวกับไฟล์แล้ว นั่นเอง
    เราไปเริ่มต้นการประยุกต์การใช้งานกับระบบสมาชิกกัน ตามลำดับ
 
    ไฟล์ nav.ejs [views/partials/nav.ejs]
<nav class="text-center">
<!-- THIS IS NAV <br> -->
<? if(!session.isLogined || session.isLogined == false){ ?>
<a href="/login">Login</a> | <a href="/register">Register</a>
<? }else{ ?>
    <a href="/me">Dashboard</a> |
    <a href="/profile">Profile</a> |    
    <a href="/changepassword">Change Password</a> |
    <a href="/me/logout">Logout</a>
<? } ?>
</nav>
    ทำการเพิ่มลิ้งค์ไปยังหน้าแก้ไขข้อมูลส่วนตัว ที่ path:"/profile"
 
 
    ไฟล์ users.js [validator/users.js]
const Joi = require('@hapi/joi')

const validation = (schema) =>{
    return ((req, res, next) => {
        // ทำการตรวจสอบความถูกต้องของข้อมูล req.body ที่ส่งมา
        Joi.validate(req.body, schema, function (error, value) {
            // กรณีเกิด error ข้อมูลไม่ผ่านการตรวจสอบ 
            if(error) {
                res.locals.errors = {
                    "message": error.details[0].message
                }
                res.locals.user = req.body
                return res.render(req.renderPage)
            } 
            if(!error) next()
        })  
    })
}

// ฟังก์ชั่นตรวจสอบข้อมูล กรณีใช้งานร่วมกับ multipart/form-data
// ใช้งานรูปแบบ cb (callback ฟังก์ชั่น)
const validationFormData = (schema, req, cb)=>{
    return Joi.validate(req.body, schema, function (error, value) {
        // กรณีเกิด error ข้อมูลไม่ผ่านการตรวจสอบ 
        if(error) { 
            cb(error.details[0].message, false)
        } 
        if(!error) cb(null, true)
    })  
}

// กำหนดชุดรูปแบบ schema
const schema = {
    register : Joi.object().keys({
        name: Joi.string().min(3).max(30).required(),
        email: Joi.string().email({ minDomainSegments: 2 }).required(),
        password:Joi.string().min(6).max(15).required(),
        confirm_password: Joi.any().valid(Joi.ref('password')).required()
            .error(errors => {return{ message:'ยืนยันรหัสผ่านไม่ถูกต้อง กรุณาลองใหม่'}}),
        _csrf:Joi.any()
    }),
    login : Joi.object().keys({
        email: Joi.string().email({ minDomainSegments: 2 }).required(),
        password:Joi.string().min(6).max(15).required(),
        remember:Joi.any(),

        _csrf:Joi.any()
    }),
    changepassword : Joi.object().keys({
        password:Joi.string().min(6).max(15).required(),
        confirm_password: Joi.any().valid(Joi.ref('password')).required()
            .error(errors => {return{ message:'ยืนยันรหัสผ่านไม่ถูกต้อง กรุณาลองใหม่'}}),
        _csrf:Joi.any()
    }),    
    profile : Joi.object().keys({
        name: Joi.string().min(3).max(30).required(),
        h_avatar:Joi.any()
    })  
}

module.exports = { validation, validationFormData, schema }
 
    ในส่วนนี้เราเพิ่มฟังก์ชั่นสำหรับตรวจสอบความถูกต้องของข้อมูล กรณีใช้งานร่วมกับ multipart/form-data
โดยเป็นฟังก์ชั่นในรุปแบบ callback ฟังก์ชั่น  สังเกตว่าจะต่างจากฟังก์ชั่นแรก ที่เป็น middleware ฟังก์ชั่น
    สำหรับส่วนของ schema เราเพิ่มค่าที่ฟิลด์ที่ตรวจสอบเพิ่มเติมคือ name และ h_avatar โดย name ก็คือเราจะ
ให้สามารถแก้ไขชื่อ และรูปโปรไฟล์ ได้ ส่วน h_avatar เราจะใช้สำหรับเก็บค่าชื่อไฟล์รูปที่ได้ทำการอัพโหลดไว้ กรณี
ที่แก้ไขเฉพาะชื่อ ไม่ต้องการแก้ไขรูปภาพ ก็ให้ใช้ค่ารูปภาพเดิมผ่าน h_avatar สุดท้าย เรา export ฟังก์ชั่น และ schema
ต่างๆ ไปใช้งาน
 
 
    ไฟล์ users.js [models/users.js]
const db = require('../config/db')
const bcrypt = require('bcrypt') // ใช้งาน bcrypt module
const saltRounds = 10 // กำหนดค่า salt

const Users = {
    email_exists:((req, res) => {
        return new Promise((resolve, reject)=>{
            res.locals.user = req.body        
            db.then((db)=>{ 
                db.collection('users') 
                .find({
                    email:req.body.email
                })
                .toArray( (error, results) => {
                    if(!error){
                        if(results.length==0){
                            resolve(true)
                        }else{
                            reject('อีเมลนี้ถูกใช้งานแล้ว')
                        }
                    }else{
                        reject('เกิดข้อผิดพลาด กรุณาลองใหม่')
                    }
                })
            })
        })
    }),
    login:((req, res) => {
        return new Promise((resolve, reject)=>{
            res.locals.user = req.body        
            db.then((db)=>{ 
                db.collection('users') 
                .find({
                    email:req.body.email
                })
                .toArray( (error, results) => {
                    if(!error){
                        if(results.length > 0){
                            let hash = results[0].password
                            let password = req.body.password
                            bcrypt.compare(password, hash).then((result)=>{
                                if(result == true) resolve(results)
                                if(result == false) reject('รห้สผ่านไม่ถูกต้อง กรุณาลองใหม่')
                            })                            
                        }else{
                            reject('อีเมล หรือ รห้สผ่านไม่ถูกต้อง กรุณาลองใหม่')
                        }
                    }else{
                        reject('เกิดข้อผิดพลาด กรุณาลองใหม่')
                    }
                })
            })            
        })
    }),
    register:((req, res) => {
        return new Promise((resolve, reject)=>{
            res.locals.user = req.body   
            db.then((db)=>{  
                db.collection('lastid')  
                .findOneAndUpdate({id:1},
                { $inc: { user_id: 1 }},(error, results)=>{ 
                    if(!error){
                        let password = req.body.password
                        bcrypt.hash(password, saltRounds).then((hash)=>{
                            let insertID = results.value.user_id+1
                            let user = {
                                "_id": insertID, 
                                "name": req.body.name, 
                                "email": req.body.email, 
                                "password": hash
                            }            
                            db.collection('users')
                            .insertOne(user, (error, results) => { 
                                if(!error){
                                    resolve(results)
                                }else{
                                    reject('เกิดข้อผิดพลาด กรุณาลองใหม่')
                                }                          
                            })       
                        })                                              
                    }else{
                        reject('เกิดข้อผิดพลาด กรุณาลองใหม่')
                    }                                 
                })
            }) 
        })
    }),
    userinfo:((req, res) => {
        return new Promise((resolve, reject)=>{  
            res.locals.user = req.body   
            db.then((db)=>{ 
                db.collection('users') 
                .find({
                    _id:req.session.userID
                })
                .toArray( (error, results) => {
                    if(!error){
                        if(results.length > 0){
                            resolve(results)
                        }else{
                            reject('ไม่พบข้อมูลผู้ใช้')
                        }
                    }else{
                        reject('เกิดข้อผิดพลาด กรุณาลองใหม่')
                    }
                })
            })            
        })
    }),
    changepassword:((req, res) => {
        return new Promise((resolve, reject)=>{
            res.locals.user = req.body   
            db.then((db)=>{ 
                let password = req.body.password
                bcrypt.hash(password, saltRounds).then((hash)=>{
                    let user = {
                        "password": hash
                    }  
                    db.collection('users')
                    .updateOne({
                        _id:req.session.userID
                    }, 
                    { $set: user }, (error, results) => {
                        if(!error){
                            resolve(results)                                           
                        }else{
                            reject('เกิดข้อผิดพลาด กรุณาลองใหม่')
                        }       
                    })                            
                })            
            })
        })
    }),
    editprofile:((req, res, avatar) => {
        return new Promise((resolve, reject)=>{
            res.locals.user = req.body   
            db.then((db)=>{ 
                let user = {
                    "name": req.body.name,
                    "avatar":avatar
                }  
                db.collection('users')
                .updateOne({
                    _id:req.session.userID
                }, 
                { $set: user }, (error, results) => {
                    if(!error){
                        resolve(results)                                           
                    }else{
                        reject('เกิดข้อผิดพลาด กรุณาลองใหม่')
                    }       
                })                            
          
            })
        })
    })    
}

module.exports = Users
 
    สำหรับในไฟล์ User Model เราเพิ่มคำสั่งการทำงานเกี่ยวกับการอัพเดทข้อมูลในฐานข้อมูล MongoDB ค่าที่ส่งไป
อัพเดทคือ name และ avatar  ซึ่งใน MongoDB เราไม่จำเป็นต้องไปเพิ่มฟิลด์ข้อมูล avatar เพราะในขั้นตอนการทำงาน
หากยังไม่มีฟิลด์นี้ MongoDB ก็จะสร้างฟิลด์นี้และเพิ่มค่าในขั้นตอนการอัพเดท
 
 
    ไฟล์ profile.ejs [views/pages/profile.ejs]
 
 
<!doctype html>
<html>
    <?- include('../partials/head') -?>
<body>
<?- include('../partials/header') -?>
<?- include('../partials/nav') -?>

<div class="container">
    <h1 class="text-center">PROFILE</h1>
    <? if(typeof success !== 'undefined'){ ?>
        <div class="form-group row">
            <div class="col-7 mx-auto">
                <div class="alert alert-success alert-dismissible fade show" role="alert">
                <?= success.message ?>
                <button type="button" class="close" data-dismiss="alert" aria-label="Close">
                    <span aria-hidden="true">&times;</span>
                </button>
                </div>    
            </div>
        </div>
        <? } ?>        
    <? if(typeof errors !== 'undefined'){ ?>
    <div class="form-group row">
        <div class="col-7 mx-auto">
            <div class="alert alert-warning alert-dismissible fade show" role="alert">
            <?= errors.message ?>
            <button type="button" class="close" data-dismiss="alert" aria-label="Close">
                <span aria-hidden="true">&times;</span>
            </button>
            </div>    
        </div>
    </div>
    <? } ?>        
<form class="mt-3" action="/profile?_csrf=<?= csrfToken ?>" method="POST" 
    enctype="multipart/form-data" novalidate>  
    <div class="form-group row">
        <div class="col-7 mx-auto">
            <input type="hidden" name="h_avatar" value="<?= user.avatar ?>" />
            <input type="file" class="custom-file-input"
            style="display: none" name="avatar"  id="avatar">
            <div id="thumbnail" style="width: 100px;height:100px;
            background: url('avatar/<?= user.avatar ?>') no-repeat;
            background-size: cover" 
            class="border rounded-circle bg-light mx-auto"></div>
        </div>       
    </div>          
    <div class="form-group row">
        <div class="col-7 mx-auto">
            <input type="text" class="form-control" 
            name="name" value="<?= user.name ?>"
            placeholder="Your name">
        </div>       
    </div>    
    <div class="form-group row">
        <div class="col-7 mx-auto">
            <button type="submit" class="btn btn-primary btn-block mx-auto">
            Update Profile</button>
        </div>           
    </div>
</form>

</div>

<?- include('../partials/footer') -?>
<script type="text/javascript" >
$(function () {
    
    
    $("#thumbnail").on("click",function(e){
        $("#avatar").show().click().hide();
        e.preventDefault();
    });
    $("#avatar").on("change",function(e){
        var files = this.files
        showThumbnail(files)        
    });
    
    function showThumbnail(files){
    
        $("#thumbnail").html("");
        for(var i=0;i<files.length;i++){
            var file = files[i]
            var imageType = /image.*/
            if(!file.type.match(imageType)){
                //     console.log("Not an Image");
                continue;
            }
    
            var image = document.createElement("img");
            image.className = 'rounded-circle'
            image.width = 100;
            image.height = 100;
            var thumbnail = document.getElementById("thumbnail");
            image.file = file;
            thumbnail.appendChild(image)
    
            var reader = new FileReader()
            reader.onload = (function(aImg){
                return function(e){
                    aImg.src = e.target.result;
                };
            }(image))
    
            var ret = reader.readAsDataURL(file);
            var canvas = document.createElement("canvas");
            ctx = canvas.getContext("2d");
            image.onload= function(){
                ctx.drawImage(image,100,100)
            }
        } // end for loop
    
    } // end showThumbnail
    
});
</script>    
</body>
</html>
 
    ในหน้าโพรไฟล์ เรามีการใช้งาน jQuery และประยุกต์ CSS โดยช่อน input file ไว้ และเมื่อผู้ใช้กดที่บริเวณแสดงรูป
โพรไฟล์ ก็ให้ trigger เสมือน input file ถูกคลิก ก็จะเปิดหน้าต่างให้เราเลือกไฟล์สำหรับอัพโหลด เมื่อเลือกไฟล์แล้ว ก็
จะแสดงตัวอย่างรูปในบริเวณนั้น
<form class="mt-3" action="/profile?_csrf=<?= csrfToken ?>" method="POST" 
    enctype="multipart/form-data" novalidate>  
    ในฟอร์มข้างต้น จะเห็นว่าเรามีการใช้งานรูปแบบการป้องกัน CSRF ผ่าน URL query string โดยกำหนดค่าต่อเข้าไปใน
action ซึ่งการกำหนดในรุปแบบนี้ ตัว csurf module ก็จะใช้ค่า req.query._csrf ในการตรวจสอบความถูกต้อง
<div id="thumbnail" style="width: 100px;height:100px;
background: url('avatar/<?= user.avatar ?>') no-repeat;
background-size: cover" 
class="border rounded-circle bg-light mx-auto"></div>
    สังเกตเพิ่มเติม ในส่วนของการแสดงรูปจากฐานข้อมูล เราใช้การแสดงรูปโดยกำหนดเป็นรูปพื้นหลัง url อ้างอิงเป็น
"/avatar/ชื่อไฟล์" ทั้งที่เราทำการบันทึกไว้ที่ "/uploads/avatar/" เพราะว่าเราจะทำการใช้งาน express.static ในรูปแบบ
app.use(express.static(path.join(__dirname, 'uploads')))
// เนื้อหาในบทความ http://niik.in/909
    ถ้าจำได้ เราจะกำหนดค่านี้ในไฟล์ app.js นั่นคือเราจะสามารถใช้งาน "/uploads/avatar/" เป็น "/avatar/" ได้
 
    สำหรับ คำสั่ง jQuery ด้านล่างที่ใช้สำหรับแสดงรูปพรีวิว สามารถดูเพิ่มเติมได้ที่บทความ http://niik.in/551
 
 
    ไฟล์ profile.js [routes/profile.js]
const express = require('express')
const router = express.Router()
const { validationFormData, schema } = require('../validator/users')
const Users = require('../models/users') // ใช้งาน Users model
const path = require('path') // เรียกใช้งาน path module
const multer = require('multer') // ใช้งาน Multer Module

// ส่วนของกาตั้งค่า multer
let upload = multer({ 
    storage:multer.diskStorage({
        destination: function (req, file, cb) {
            cb(null, path.join(__dirname,'..','uploads/avatar'))
        },
        filename: function (req, file, cb) {
            let extArr = file.originalname.split('.')
            let ext = extArr[extArr.length-1]
            cb(null, req.session.userID + '.' + ext)
			// ในที่นี้ เราใช้ชื่อไฟล์เป็น id ของ user จากค่า session เช่น จะได้ค่าเป็น 10002.jpg
        }
    }),
    fileFilter:(req, file, cb)=>{
        if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
            return cb(new Error('เฉพาะไฟล์รูปภาพเท่านั้น!'), false)
        }
        cb(null, true)
    },
    limits:{
        fileSize:2000000 // ขนาดไฟล์ไม่เกิน 2MB
    }
}).single('avatar') // อัพโหลดไฟล์ จากฟิลด์เดียวชื่อ avatar


router.route('/')
    .all((req, res, next) => { 
        // ตัวแปรที่กำหนดด้วย res.locals คือค่าจะส่งไปใช้งานใน template
        res.locals.pageData = {
            title:'Profile Page'
        }
        // ค่าที่จะไปใช้งาน ฟอร์ม ใน template 
        res.locals.user = {
            name:''
        }
        // generate csrfTOken
        res.locals.csrfToken = req.csrfToken()           
        next()
    })
    .get((req, res, next) => { 
		// หากเปิดมายังหน้าแก้ไขข้อมูลส่วนตัว ดึงข้อมูล นำค่าในฐานข้อมูลมาแสดง
        Users.userinfo(req, res).then(
            (user)=>{
                res.locals.user = user[0]   
                if(req.cookies.flash_message){
                    // ถ้ามี นำค่าไปใกำหนดในตัวแปร res.locals เพื่อใช้งานใน template
                    res.locals.success = {
                        message:req.cookies.flash_message
                    }
                }                
                res.render('pages/profile')                 
            }
        )              
    })
    .post((req, res, next) => { 
		// ใช้งานรูปแบบการอัพโหลดไฟล์ รองรับการจัดการ error
        upload(req, res, (error)=>{
			// กรณี error
            if (error instanceof multer.MulterError || error) {
                res.locals.errors = { "message": error }   
                res.locals.user = req.body  
                return res.render('pages/profile')      
            }
			// หากไม่เกิด error ในขั้นตอนการอัพโหลดไฟล์ ก็ให้ทำงานฟังก์ชั่น ตรวจสอบความถูกต้องของข้อมูลต่อ
            validationFormData(schema.profile, req, (error, result)=>{
                if(error){ // หากเกิด error
                    res.locals.errors = { "message": error }     
                    res.locals.user = req.body  
                    return res.render('pages/profile')                                                
                }
				// หากตรวจสอบข้อมูลผ่าน 
                let avatar = req.body.h_avatar // ให้ชื่อรูปเท่ากับค่าเดิม ถ้ามี
                if(typeof req.file !== 'undefined'){ // แต่ถ้ามีการอัพโหลดไฟล์ใหม่เข้ามา
                    avatar = req.file.filename // ให้ใช้ชื่อไฟล์ใหม่
                }
				// ทำคำสั่งอัพเดทข้อมูลในฐานข้อมูล โดยใช้งาน User model
                Users.editprofile(req, res, avatar).then(
                    (results)=>{ 
                        res.cookie('flash_message', 'ทำการแก้ไขข้อมูลเรียบร้อยแล้ว',{maxAge:3000})         
                        res.redirect('/profile')   
                    },
                    (error)=>{
                        res.locals.errors = { "message": error }                 
                        return res.render('pages/profile')    
                    }            
                )                      
            })
        })
    })

module.exports = router
    จะเห็นว่าในส่วนของการอัพเดทข้อมูล เราจะทำการใช้งานฟังก์ชั่นอัพโหลดก่อน แล้วถึงจะสามารถใช้งานฟังก์ชั่น
ตรวจสอบความถูกต้องของข้อมูล แล้วต่อด้วยการใช้งานฟังก์ชั่นอัพเดทลองฐานข้อมูล
upload(req, res, (error)=>{
	validationFormData(schema.profile, req, (error, result)=>{
		Users.editprofile(req, res, avatar).then()
	})
})
 
 
    ไฟล์ dashboard.js [routes/dashboard.js]
<!doctype html>
<html>
    <?- include('../partials/head') -?>
<body>
<?- include('../partials/header') -?>
<?- include('../partials/nav') -?>

<div class="container">
    <h1 class="text-center">Hello</h1>
    <div class="text-center my-3 border p-3">
        <div id="thumbnail" style="width: 100px;height:100px;
        background: url('avatar/<?= user.avatar ?>') no-repeat;
        background-size: cover" 
        class="border rounded-circle bg-light mx-auto"></div>        
        <?= user.name ?>
        <br/>
        <?= user.email ?>
    </div>
    <div class="text-center">

    </div>
</div>

<?- include('../partials/footer') -?>
</body>
</html>
    ส่วนสุดท้ายแสดงรูปในหน้า Dashboard 
 
 
 

ทดสอบการใช้งาน Multer ในระบบสมาชิก

    เรามาเริ่มทดสอบการทำงาน เริ่มต้นเรายังไม่ได้ทำการอัดโหลดไฟล์ใดๆ 
 
 

 
 
    เมื่อเรามายังหน้าโพรไฟล์ เลือกรูปภาพ และเปลี่ยนข้อมูลใน name เป็น Monika Lous
 
 

 
 
    จะเห็นว่าในขั้นตอนการเลือกไฟล์รูป รูปพรีวิวจะแสดงทันที จากการใช้งาน jQuery ในขั้นตอนนี้ เรายังไม่ได้
ทำการบันทึกข้อมูล
 
 

 
 
    หลังจากเราทำการกดอัพเดทโพรไฟล์เพื่อบันทึกข้อมูล และอัพโหลดไฟล์ ตอนนี้รูปที่แสดง จะเป็นรูปที่ดึงโฟลเดอร์
ที่ได้อัพโหลดไว้

 

 
 
    มีไฟล์ 1002.jpg อยู่ในโฟลเดอร์ "uploads/avatar" ชื่อไฟล์เราใช้รูปแบบอ้าอิงจาก userID ของสมาชิก นั่นคือ
หากมีการแก้ไขหรืออัพโหลดรูปใหม่ ไฟล์เดิมก็จะถูกทับเป็นรูปใหม่ทันที
    เราลองกดลิ้งค์ไปยังหน้าสมาชิก Dashboard 
 
 

 
 
    จะเห็นว่าตอนนี้ข้อมูลที่แสดง เป็นข้อมูลที่ดึงจากฐานข้อมูล ใช้รูปภาพจากไฟล์ที่อัพโหลด เป็นอันเสร็จเรียบร้อยสำหรับ
แนวทางการใช้งาน Multer 
    หวังว่าเนื้อหานี้ จะเป็นแนวทางสำหรับประยุกต์ใช้งาน หรือสำหรับศึกษาเนื้อหาอื่นๆ ต่อไป


กด Like หรือ Share เป็นกำลังใจ ให้มีบทความใหม่ๆ เรื่อยๆ น่ะครับ







เนื้อหาที่เกี่ยวข้อง






เนื้อหาพิเศษ เฉพาะสำหรับสมาชิก

กรุณาล็อกอิน เพื่ออ่านเนื้อหาบทความ

ยังไม่เป็นสมาชิก

สมาชิกล็อกอิน



( หรือ เข้าใช้งานผ่าน Social Login )




URL สำหรับอ้างอิง











เว็บไซต์ของเราให้บริการเนื้อหาบทความสำหรับนักพัฒนา โดยพึ่งพารายได้เล็กน้อยจากการแสดงโฆษณา โปรดสนับสนุนเว็บไซต์ของเราด้วยการปิดการใช้งานตัวปิดกั้นโฆษณา (Disable Ads Blocker) ขอบคุณครับ