Discover Meteor

Building Real-Time JavaScript Web Apps

บทนำ

1

ลองคิดในใจดู สมมติว่าตอนนี้คุณกำลังเปิดโปรแกรมจัดการไฟล์ขึ้นมา 2 หน้าต่าง แต่อยู่ที่โฟลเดอร์เดียวกัน

คราวนี้ลองลบไฟล์ในโฟลเดอร์นั้นจากหน้าต่างหนึ่งดูสิ ไฟล์นั้นหายไปจากอีกหน้าต่างด้วยหรือเปล่า?

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

คราวนี้ ถ้าเกิดอยู่บนเว็บสถานการณ์จะเป็นอย่างไร สมมุติว่าคุณเปิดหน้าแอดมินของ WordPress ไว้ 2 หน้าต่าง แล้วคุณก็กดสร้างโพสต์ในหน้าต่างหนึ่ง แต่คราวนี้ ไม่ว่าคุณจะรอนานเพียงใด คุณก็คงไม่เห็นว่าอีกหน้าต่างหนึ่งจะมีการเปลี่ยนแปลงใดๆเกิดขึ้น จนกว่าคุณจะกดรีเฟรชด้วยตัวเอง

ในช่วงหลายปีที่ผ่านมา เราต่างก็คุ้นเคยอยู่กับความคิดเดิมๆ ที่ว่า เว็บไซต์เป็นเพียงแค่การสื่อสารช่วงสั้นๆ คือ ขอข้อมูลไปและรับข้อมูลกลับมาแค่นั้น

แต่ในตอนนี้เฟรมเวิร์กและเทคโนโลยีรุ่นใหม่ๆอย่าง Meteor กำลังท้าทายแนวคิดเดิม ด้วยการทำให้เว็บตอบสนองต่อข้อมูลและเหตุการณ์ต่างๆ ได้ทันที (reactive and real-time)

Meteor คืออะไร

Meteor เป็นแพลตฟอร์มที่ต่อยอดจาก Node.js เพื่อการสร้างเว็บแอพแบบเรียลไทม์ มันทำงานอยู่ระหว่างฐานข้อมูล (database) และส่วนติดต่อผู้ใช้ (user interface) โดยทำให้ข้อมูลทั้งสองฝั่งนั้นสอดคล้องกันอยู่เสมอ

การพัฒนาต่อยอดจาก Node.js ทำให้ Meteor ใช้ภาษาจาวาสคริปต์ได้ทั้งบนฝั่งไคลเอนต์และเซิร์ฟเวอร์ ยิ่งไปกว่านั้น Meteor ยังสามารถใช้โค้ดร่วมกันได้ทั้งสองฝั่งอีกด้วย

ผลลัพธ์ที่ได้ก็คือ แพลตฟอร์มที่ทรงพลังแต่เรียบง่าย ลดขั้นตอนที่ยุ่งยากรวมทั้งอุปสรรคต่างๆในการพัฒนาเว็บแอปพลิเคชันออกไป

ทำไมต้องเป็น Meteor

ทำไมคุณถึงควรเรียน Meteor มากกว่าเฟรมเวิร์กตัวอื่นๆ ล่ะ … เราเชื่อว่าเหตุผลสำคัญที่สุดก็คือ Meteor เป็นเฟรมเวิร์กที่เรียนรู้ได้ง่าย ทั้งยังมีจุดเด่นอีกหลายอย่างที่เรายังไม่ได้พูดถึง

Meteor ช่วยให้คุณสามารถสร้างเว็บแอปพลิเคชันแบบเรียลไทม์ และส่งมันขึ้นไปอยู่บนเว็บได้ในเวลาเพียงแค่ไม่กี่ชั่วโมง (ใช้เวลาน้อยกว่าเฟรมเวิร์กตัวอื่นๆ มาก) ยิ่งถ้าคุณเคยพัฒนาเว็บในฝั่ง front-end มาก่อน และพอคุ้นเคยกับภาษาจาวาสคริปต์มาบ้าง คุณก็ไม่มีความจำเป็นต้องเรียนรู้ภาษาใหม่อะไรอีก

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

ทำไมต้องเป็นหนังสือเล่มนี้ด้วย

สองสามปีที่ผ่านมา เราได้ร่วมงานในโครงการที่ใช้ Meteor หลายตัว ตั้งแต่เว็บจนถึงโมบายล์แอพ ทั้งในรูปแบบธุรกิจและโอเพ่นซอร์ส

เราได้เรียนรู้อะไรหลายๆอย่าง ซึ่งบางครั้งก็ไม่ง่ายกว่าจะได้คำตอบที่ต้องการ เราต้องอาศัยการปะติดปะต่อข้อมูลจากหลายๆที่เข้าด้วยกัน และมีหลายครั้งที่เราต้องคิดค้นวิธีแก้ปัญหากันเอง ในหนังสือเล่มนี้เราจึงอยากแบ่งปันบทเรียนต่างๆที่เราได้รับ ให้เป็นคู่มือง่ายๆ ที่จะสอนคุณสร้างแอปพลิเคชันโดยใช้ Meteor ทีละขั้นตอน ตั้งแต่ต้นจนจบ

โดยเราจะสอนให้คุณสร้างเว็บข่าวสังคมแบบง่ายๆ คล้ายกับ Hacker news หรือ Reddit ที่เราเรียกว่า Microscope (ตั้งชื่อตามพี่ใหญ่ Telescope ที่เป็นแอพ Meteor แบบโอเพ่นซอร์ส) ในระหว่างการสร้าง เราก็จะแนะนำส่วนประกอบต่างๆ ของ Meteor ไปทีละอย่าง เช่น ระบบบัญชีผู้ใช้ (user accounts), คอลเลคชั่น (collections), การจัดเส้นทาง (routing) ฯลฯ

หนังสือเล่มนี้เหมาะสำหรับใคร

เป้าหมายหนึ่งที่เราตั้งใจไว้ตอนที่เขียนหนังสือเล่มนี้ก็คือ ให้เนื้อหาเป็นกันเอง และเข้าใจได้ง่าย แม้ว่าคุณจะไม่เคยใช้ทั้ง Meteor, Node.js, และเฟรมเวิร์ก MVC ต่างๆ หรือไม่เคยแม้กระทั่งเขียนโค้ดฝั่งเซิร์ฟเวอร์มาก่อนเลย คุณก็ยังสามารถอ่านและทำตามที่หนังสือบอกได้

ขอเพียงแค่คุณเข้าใจพื้นฐานการเขียนจาวาสคริปต์เบื้องต้นก็พอ ยิ่งถ้าคุณเคยแก้โค้ด jQuery หรือเปิดใช้งานเว็บเบราว์เซอร์ในโหมดนักพัฒนาเพื่อทำอะไรบางอย่างมาบ้างแล้ว คุณก็น่าจะไปได้สวยเลยล่ะ

ถ้าคุณยังไม่คุ้นเคยกับจาวาสคริปต์ เราแนะนำให้เข้าไปอ่านเพิ่มเติมจากหน้า จาวาสคริปต์สำหรับผู้เริ่มต้น ของเรา ก่อนที่จะเริ่มต้นกับหนังสือเล่มนี้

รู้จักทีมผู้เขียน

เพื่อคลายความสงสัยและให้คุณรู้จักเรามากขึ้น ข้อมูลตรงนี้ก็คือประวัติย่อๆ ของพวกเราทั้งคู่

Tom Coleman เป็นหนึ่งใน Percolate Studio, สตูดิโอรับทำเว็บ ที่เน้นคุณภาพและการใช้งาน เขาเป็นหนึ่งในผู้ดูแลระบบ Atmosphere ที่เป็นแหล่งรวมแพ็คเกจของ Meteor และยังเป็นมันสมองผู้อยู่เบื้องหลังโครงการโอเพนซอร์สที่ใช้ Meteor อีกหลายตัว (เช่น Iron Router)

Sacha Greif เคยร่วมงานกับ startup ต่างๆ เช่น Hipmunk และ RubyMotion ในฐานะนักออกแบบผลิตภัณฑ์และเว็บไซต์ โดยเขาเป็นทั้งผู้สร้าง Telescope และ Sidebar (ต่อยอดจาก Telescope), และยังเป็นผู้ก่อตั้ง Folyo อีกด้วย

บทเรียนหลัก และบทแทรก

เพื่อให้หนังสือเล่มนี้เป็นประโยชน์ทั้งกับมือใหม่หัดใช้ Meteor และโปรแกรมเมอร์ขั้นเทพ เราจึงแบ่งบทเรียนออกเป็นสองแบบ คือ บทเรียนหลัก (บทที่ 1 ถึง 14) และบทเแทรก (บทที่ลงท้ายด้วย 0.5)

ในบทเรียนหลัก เราจะสอนคุณสร้างแอพ โดยอธิบายเฉพาะขั้นตอนที่สำคัญๆ และไม่ลงรายละเอียดมากเกินไป เพื่อให้คุณเริ่มเขียนโค้ดได้เร็วที่สุด

ส่วนในบทแทรก เราจะอธิบายเจาะลึกถึงความสลับซับซ้อนของ Meteor เพื่อให้คุณมีความเข้าใจถึงการทำงานที่เกิดขึ้นจริงๆ

ถ้าคุณเป็นมือใหม่ จะข้ามบทแทรกไปก่อนก็ได้ แล้วค่อยกลับมาอ่านทีหลังเมื่อคุณเริ่มคุ้นเคยกับ Meteor แล้ว

โค้ดสำเร็จ และหน้าทดสอบ

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

เพื่อป้องกันปัญหานี้ เมื่อเราสอนให้คุณเขียนโค้ดขึ้นชุดหนึ่ง เราก็จะมีปุ่มดูโค้ด บน GitHub repository ของ Microscope และปุ่มเปิดหน้าทดสอบ ใช้เปิดหน้าแอพของโค้ดชุดนั้นไว้ด้วย เพื่อให้คุณใช้เปรียบเทียบกับโค้ดที่คุณกำลังเขียนได้ทันที โดยมีรูปแบบตามตัวอย่างที่คุณเห็นข้างล่างนี้

คอมมิท 11-2

Display notifications in the header.

แต่คุณก็ต้องจำไว้ว่า ถึงแม้เราได้เตรียมโค้ดตัวอย่างที่สำเร็จไว้ให้แล้ว คุณก็ไม่ควรใช้คำสั่ง git checkout เพื่อข้ามไปยังขั้นตอนถัดไปทันที เพราะว่าคุณจะเรียนรู้ได้ดีขึ้น ถ้ายอมเสียเวลาป้อนโค้ดด้วยตัวเอง !

แหล่งข้อมูลอื่นๆ

ถ้าคุณต้องการเรียนรู้เพิ่มเติมเกี่ยวกับคุณลักษณะเฉพาะของ Meteor จุดเริ่มต้นที่ดีที่สุดก็คือ หน้าเอกสารของ Meteor

นอกจากนี้ เราขอแนะนำเว็บบอร์ดที่ Stack Overflow ซึ่งเป็นแหล่งรวมข้อมูลวิธีแก้ไขปัญหาและคำถามต่างๆของ Meteor เอาไว้ และที่ #meteor IRC channel ในกรณีที่คุณต้องการความช่วยเหลือแบบสดๆ

จำเป็นต้องใช้ Git มั้ย

เราแนะนำว่าควรใช้ ถึงแม้ว่าหนังสือเล่มนี้ไม่ได้เจาะจงว่าคุณจำเป็นต้องใช้ Git เป็น ถึงจะสร้างแอพได้

ถ้าคุณต้องการเรียนรู้ Git อย่างรวดเร็ว เราแนะนำหน้าเว็บ Git Is Simpler Than You Think ของ Nick Farina

หรือถ้าคุณเป็นมือใหม่ เราแนะนำให้คุณใช้แอพ GitHub บนเครื่อง Mac เพื่อทำสำเนาและจัดการ repository ได้ โดยไม่ต้องยุ่งกับ command line เลย หรือจะใช้ SourceTree ที่รันได้ทั้ง Mac และ Windows ก็ได้ ซึ่งสองตัวนี้เป็นโปรแกรมที่ดาวน์โหลดได้ฟรี

พูดคุยกับเรา

เริ่มต้นกับ Meteor

2

Meteor จะทำให้คุณประทับใจตั้งแต่แรกพบ ขั้นตอนการติดตั้งทำได้ง่ายๆ เพียงไม่เกินห้านาที คุณก็พร้อมใช้งานได้แล้ว

ถ้าคุณใช้ Mac หรือ Linux คุณสามารถติดตั้ง Meteor โดยเปิดโปรแกรมเทอร์มินัล และป้อนคำสั่งนี้

curl https://install.meteor.com | sh

แต่ถ้าคุณใช้ Windows ให้คุณทำตาม คำแนะนำการติดตั้ง จากเว็บไซต์ Meteor ได้เลย

เมื่อทำเสร็จแล้ว คุณก็จะได้โปรแกรม meteor ที่พร้อมใช้งานบนเครื่องของคุณ

ใช้ Meteor โดยไม่ต้องติดตั้ง

ถ้าหากคุณไม่สามารถติดตั้ง (หรือไม่ต้องการติดตั้ง) Meteor ลงบนเครื่อง คุณก็น่าจะลองใช้ Nitrous.io ดูนะ

Nitrous.io เป็นบริการที่ช่วยให้คุณสามารถรันแอพและแก้ไขโค้ดจากเบราว์เซอร์ได้โดยตรง ซึ่งทางเราได้เตรียม คู่มือการใช้งานฉบับย่อ ไว้ให้แล้ว

คุณก็แค่ทำตามคู่มือนั้นจนจบหัวข้อ “Installing Meteor” แล้วกลับมาอ่านต่อที่ “ลองสร้างแอพแบบง่ายๆกัน” ในบทนี้ได้เลย

ลองสร้างแอพแบบง่ายๆกัน

เมื่อ Meteor พร้อมแล้ว ก็ถึงเวลาที่เราจะมาสร้างแอพกัน เริ่มต้นด้วยการเรียกใช้คำสั่ง meteor ดังนี้

meteor create microscope

คำสั่งนี้เป็นการบอกให้ Meteor ทำการดาวน์โหลดไฟล์ที่จำเป็นและสร้างแอพพื้นฐานให้เรา เมื่อเสร็จเรียบร้อยคุณก็จะได้โฟลเดอร์ microscope/ ที่ประกอบด้วยไฟล์ต่อไปนี้

.meteor
microscope.css  
microscope.html 
microscope.js   

โดยแอพที่ Meteor สร้างให้เรานั้น เป็นแอพตัวอย่างที่มีการทำงานแบบง่ายๆ

ถึงแม้ว่ามันจะทำงานอะไรได้ไม่มากนัก เราก็ยังสามารถรันมันได้ ที่คุณต้องทำก็แค่ป้อนคำสั่งต่อไปนี้ที่หน้าต่างเทอร์มินัล

cd microscope
meteor

จากนั้นก็เปิดเบราว์เซอร์ไปที่ http://localhost:3000/ (หรือที่ http://0.0.0.0:3000) คุณก็น่าจะเห็นอะไรคล้ายๆแบบนี้

Meteor's Hello World.
Meteor’s Hello World.

คอมมิท 2-1

Created basic microscope project.

ยินดีด้วย ! คุณได้สร้างแอพ Meteor ตัวแรกที่ใช้งานได้แล้ว ถ้าจะปิดมันคุณก็แค่กด ctrl+c ในหน้าต่างเทอร์มินัลที่คุณรันมันไว้ ก็เรียบร้อย

และถ้าคุณใช้ Git อยู่ ก็ถึงเวลาที่คุณควรสร้าง repository ด้วยคำสั่ง git init ได้แล้ว

ลาก่อนนะ Meteorite

ก่อนหน้านี้การใช้งาน Meteor ยังต้องพึ่งพาโปรแกรมจัดการแพ็คเกจที่เราเรียกว่า Meteorite แต่หลังจากเวอร์ชัน 0.9.0 เป็นต้นมา ก็ไม่จำเป็นอีกต่อไป เนื่องจากคุณสมบัติต่างๆของ Meteorite ได้ถูกรวมเข้าเป็นส่วนหนึ่งของ Meteor แล้ว

ถ้าคุณเผอิญไปเจอตรงไหนในหนังสือหรือในเอกสารที่เกี่ยวข้องกับ Meteor ที่อ้างถึงคำสั่ง mrt ของ Meteorite แล้วล่ะก็ ให้คุณใช้คำสั่ง meteor แทนได้เลย

การเพิ่มแพ็คเกจ

ขั้นตอนต่อไป เราจะลองใช้ระบบจัดการแพ็คเกจของ Meteor เพิ่มเฟรมเวิร์ก Bootstrap เข้าไปในแอพของเรา

ซึ่งอันที่จริงแล้ว ก็ไม่แตกต่างอะไรกับการที่เราจะก๊อปไฟล์ CSS และ JavaScript จาก Bootstrap มาลงเอง เว้นเสียแต่ว่าในกรณีนี้เรามีผู้ดูแลแพ็คเกจที่คอยอัพเดทแพ็คเกจนี้ให้เราอยู่เสมอ

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

โดยแพ็คเกจ bootstrap ตัวนี้ สร้างและดูแลโดยผู้ใช้ชื่อ twbs มีชื่อเต็มของแพ็คเกจว่า twbs:bootstrap

ส่วนแพคเกจ underscore ก็เป็นส่วนหนึ่งของแพคเกจอย่างเป็นทางการของ Meteor และมาพร้อมกับเฟรมเวิร์กอยู่แล้ว จึงไม่ระบุชื่อผู้สร้าง

meteor add twbs:bootstrap
meteor add underscore

ให้สังเกตว่าแพคเกจที่เราเพิ่มเป็น Bootstrap 3 แต่ภาพหน้าจอในหนังสือบางภาพได้มาจาก Microscope เวอร์ชันเก่าที่ยังใช้ Bootstrap 2 อยู่ จึงอาจจะมีความแตกต่างกันอยู่บ้าง

คอมมิท 2-2

Added bootstrap and underscore packages.

หลังจากที่คุณเพิ่มแพคเกจ Bootstrap เข้ามา คุณก็จะสังเกตเห็นความเปลี่ยนแปลงของแอพได้ ดังภาพ

With Bootstrap.
With Bootstrap.

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

รู้จักกับแพ็คเกจชนิดต่างๆ

เมื่อพูดถึงแพ็คเกจในโลกของ Meteor จะพบว่าสามารถแบ่งออกได้เป็น 5 ประเภทด้วยกัน คือ

  • แพ็คเกจหลัก (Platform package) คือกลุ่มแพ็คเกจที่ประกอบกันขึ้นเป็น Meteor และบรรจุไว้ในแอพ Meteor ทุกตัว โดยที่คุณไม่ต้องจัดการอะไรเพิ่มเติม
  • แพ็คเกจปกติ (Regular package) หรือที่รู้จักกันในชื่อ “ไอโซแพ็ค (isopacks)” คือแพ็คเกจที่สามารถใช้งานได้กับโค้ดทั้งฝั่งไคลเอนต์และฝั่งเซิร์ฟเวอร์ เราเรียกไอโซแพคที่พัฒนาโดยทีมงาน Meteor และมาพร้อมกับ Meteor ว่า แพ็คเกจ First-party เช่น accounts-ui หรือ appcache
  • ส่วน แพ็คเกจ Third-party ก็คือไอโซแพ็คอีกแบบหนึ่ง ที่พัฒนาโดยผู้ใช้รายอื่นและส่งขึ้นไปเก็บไว้ที่เซิร์ฟเวอร์จัดการแพ็คเกจของ Meteor ที่คุณสามารถเข้าไปค้นหาได้จากเว็บ Atmosphere หรือจากคำสัั่ง meteor search
  • แพ็คเกจ Local คือ แพ็คเกจที่คุณสร้างขึ้นใช้เองและเก็บไว้ในโฟลเดอร์ /packages
  • แพ็คเกจ NPM (Node.js Packaged Module) คือแพ็คเกจของ Node.js ซึ่งแม้ว่าเราจะไม่สามารถเรียกใช้งานจาก Meteor ได้โดยตรง แต่ก็สามารถเรียกใช้ได้จากแพ็คเกจแบบอื่นๆข้างต้นได้

โครงสร้างโฟลเดอร์ของแอพ Meteor

ก่อนจะเริ่มเขียนโค้ดกัน เราควรจัดการแอพให้เข้าที่เข้าทางซะก่อน และเพื่อให้มั่นใจว่า จะไม่มีส่วนเกินอะไรติดมา ให้เราเปิดโฟลเดอร์ microscope และลบไฟล์ microscope.html microscope.js และ microscope.css ทั้งหมดทิ้งไปซะ

จากนั้นให้สร้างโฟลเดอร์ย่อยขึ้นใน /microscope ทั้งหมด 4 โฟลเดอร์ คือ /client /server /public และ /lib

ต่อมา ให้สร้างไฟล์ว่างๆชื่อ main.html และ main.js ในโฟลเดอร์ /client โดยไม่ต้องกลัวว่าแอพจะรันไม่ได้ เพราะในบทต่อไปเราก็จะเริ่มเขียนโค้ดลงในไฟล์สองตัวนี้

สิ่งที่เราคงต้องพูดถึงก่อน คือความพิเศษของโฟลเดอร์บางตัว ที่มีผลต่อการทำงานของแอพ โดย Meteor มีกฎเบื้องต้นดังนี้

  • โค้ดในโฟลเดอร์ /server จะทำงานที่ฝั่งเซิร์ฟเวอร์เท่านั้น
  • โค้ดในโฟลเดอร์ /client จะทำงานที่ฝั่งไคลเอนต์เท่านั้น
  • โค้ดในที่อื่นๆ จะทำงานได้ทั้งบนฝั่งเซิร์ฟเวอร์และฝั่งไคลเอนต์
  • ไฟล์อื่นๆ (ฟอนต์ ภาพ ฯลฯ) ต้องเก็บไว้ที่โฟลเดอร์ /public

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

  • ไฟล์ใน /lib จะถูกโหลด ก่อน ไฟล์อื่น
  • ไฟล์ main.* จะถูกโหลด หลัง ไฟล์อื่น
  • ไฟล์อื่นๆนอกจากนี้ จะถูกโหลดตามลำดับของตัวอักษรในชื่อไฟล์

ถึงแม้ว่าจะมีกฎเกณฑ์แบบนี้ แต่ Meteor ก็ไม่ได้บังคับให้คุณต้องใช้โครงสร้างโฟลเดอร์ตามที่กำหนดหากคุณไม่ต้องการ อีกทั้งรูปแบบที่เราแนะนำไป ก็เป็นแค่วิธีที่เราใช้ ไม่ใช่อะไรที่ตายตัว

ถ้าคุณยังต้องการรายละเอียดที่มากกว่านี้ เราอยากให้คุณไปดูที่ หน้าเอกสารของ Meteor

Meteor เป็น MVC มั้ย

ถ้าคุณเคยใช้เฟรมเวิร์กอื่นเช่น Ruby on Rails มาก่อน แล้วเพิ่งมาใช้ Meteor คุณคงสงสัยว่า Meteor ได้นำรูปแบบ MVC (Model View Controller) มาใช้ในแอพหรือไม่

คำตอบสั้นๆ ก็คือ ไม่

ที่ไม่เหมือน Rails ก็คือ Meteor ไม่ได้กำหนดโครงสร้างอะไรกับแอพของคุณ และในหนังสือเล่มนี้ เราก็แค่วางรูปแบบโค้ดที่มันสมเหตุสมผล โดยไม่ต้องมีตัวย่อต่างๆมาทำให้เราวุ่นวาย

ไม่มีโฟลเดอร์ public เลย

จริงๆแล้ว เราหลอกคุณ

เหตุผลง่ายๆ ที่ไม่จำเป็นต้องมีโฟลเดอร์ public/ ก็เพราะว่า Microscope ไม่มีไฟล์แบบอื่นๆเลย แต่เนื่องจากแอพของ Meteor ส่วนมากจะต้องมีภาพบ้างอย่างน้อยก็หนึ่งภาพ เราเลยคิดว่า มีไว้ก็น่าจะดีกว่าไม่มี

ก่อนที่จะลืม คุณอาจสังเกตเห็นโฟลเดอร์ .meteor ที่ซ่อนอยู่ ซึ่ง Meteor ใช้เก็บโค้ดของตัวมันเอง เราจึงไม่ควรแก้ไขอะไรข้างในนั้น ว่าไปแล้วคุณไม่จำเป็นต้องสนใจมันเลยก็ได้ จะมีก็แต่ไฟล์ .meteor/packages และ .meteor/release ที่ใช้เก็บรายชื่อแพ็คเกจ และเวอร์ชันของ Meteor ที่ใช้อยู่ เมื่อไรก็ตามที่คุณเพิ่มแพ็คเกจและปรับรุ่น Meteor แล้วละก็ คุณอาจจำเป็นต้องตรวจสอบการเปลี่ยนแปลงในไฟล์พวกนี้

Underscores กับ CamelCase

สิ่งเดียวที่เราจะพูดถึงเกี่ยวกับการตั้งชื่อตัวแปร ทั้งแบบ underscore (my_variable) และแบบ camelCase (myVariable) ก็คือ ไม่ว่าคุณจะใช้แบบไหน มันก็ไม่มีผลอะไร ตราบใดที่คุณยังคงใช้แบบนั้นอยู่

ในหนังสือเล่มนี้ เราใช้ camelCase ก็เพราะว่ามันเป็นเรื่องปกติในจาวาสคริปต์ (ที่เห็นชัดๆ ก็คือ คงไม่มีใครเขียน JavaScript ว่า java_script แน่ๆ !)

แต่มีข้อยกเว้นเพียงประการเดียวกับกฎเกณฑ์นี้ก็คือ ชื่อไฟล์เราจะใช้ ตัวอักษรขีดล่าง (เช่น my_file.js) ส่วนคลาสใน CSS เราจะใช้ เครื่องหมายลบ (เช่น .my-class) เหตุผลก็คือ ตัวอักษรขีดล่างถูกใช้กับระบบไฟล์มากที่สุด ในขณะที่ CSS เองก็ใช้เครื่องหมายลบกันอยู่แล้ว (font-family text-align ฯลฯ)

ใส่ใจกับ CSS

หนังสือเล่มนี้ไม่เกี่ยวกับ CSS ดังนั้นเพื่อไม่ให้คุณเสียเวลา เราจึงตัดสินใจสร้าง stylesheet ทั้งหมดให้พร้อมใช้ไว้ตั้งแต่แรก เพื่อที่คุณจะได้ไม่ต้องกังวลกับมันอีก

โดยไฟล์ CSS นั้นจะถูกโหลดและทำให้เล็กลงอย่างอัตโนมัติด้วย Meteor และคุณควรเก็บมันไว้ในโฟลเดอร์ /client ไม่ใช่ใน /public เหมือนกับไฟล์ชนิดอื่นๆ

ที่คุณต้องทำต่อคือ สร้างโฟลเดอร์ client/stylesheets/ และวางไฟล์ style.css ที่มีข้อมูลตามที่แสดงนี้ไว้ข้างใน

.grid-block, .main, .post, .comments li, .comment-form {
  background: #fff;
  border-radius: 3px;
  padding: 10px;
  margin-bottom: 10px;
  -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
  -moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
  box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15); }

body {
  background: #eee;
  color: #666666; }

#main {
  position: relative;
}
.page {
  position: absolute;
  top: 0px;
  width: 100%;
}

.navbar {
  margin-bottom: 10px; }
  /* line 32, ../sass/style.scss */
  .navbar .navbar-inner {
    border-radius: 0px 0px 3px 3px; }

#spinner {
  height: 300px; }

.post {
  /* For modern browsers */
  /* For IE 6/7 (trigger hasLayout) */
  *zoom: 1;
  position: relative;
  opacity: 1; }
  .post:before, .post:after {
    content: "";
    display: table; }
  .post:after {
    clear: both; }
  .post.invisible {
    opacity: 0; }
  .post.instant {
    -webkit-transition: none;
    -moz-transition: none;
    -o-transition: none;
    transition: none; }
  .post.animate{
    -webkit-transition: all 300ms 0ms;
    -moz-transition: all 300ms 0ms ease-in;
    -o-transition: all 300ms 0ms ease-in;
    transition: all 300ms 0ms ease-in; }
  .post .upvote {
    display: block;
    margin: 7px 12px 0 0;
    float: left; }
  .post .post-content {
    float: left; }
    .post .post-content h3 {
      margin: 0;
      line-height: 1.4;
      font-size: 18px; }
      .post .post-content h3 a {
        display: inline-block;
        margin-right: 5px; }
      .post .post-content h3 span {
        font-weight: normal;
        font-size: 14px;
        display: inline-block;
        color: #aaaaaa; }
    .post .post-content p {
      margin: 0; }
  .post .discuss {
    display: block;
    float: right;
    margin-top: 7px; }

.comments {
  list-style-type: none;
  margin: 0; }
  .comments li h4 {
    font-size: 16px;
    margin: 0; }
    .comments li h4 .date {
      font-size: 12px;
      font-weight: normal; }
    .comments li h4 a {
      font-size: 12px; }
  .comments li p:last-child {
    margin-bottom: 0; }

.dropdown-menu span {
  display: block;
  padding: 3px 20px;
  clear: both;
  line-height: 20px;
  color: #bbb;
  white-space: nowrap; }

.load-more {
  display: block;
  border-radius: 3px;
  background: rgba(0, 0, 0, 0.05);
  text-align: center;
  height: 60px;
  line-height: 60px;
  margin-bottom: 10px; }
  .load-more:hover {
    text-decoration: none;
    background: rgba(0, 0, 0, 0.1); }

.posts .spinner-container{
  position: relative;
  height: 100px;
}

.jumbotron{
  text-align: center;
}
.jumbotron h2{
  font-size: 60px;
  font-weight: 100;
}

@-webkit-keyframes fadeOut {
  0% {opacity: 0;}
  10% {opacity: 1;}
  90% {opacity: 1;}
  100% {opacity: 0;}
}

@keyframes fadeOut {
  0% {opacity: 0;}
  10% {opacity: 1;}
  90% {opacity: 1;}
  100% {opacity: 0;}
}

.errors{
  position: fixed;
  z-index: 10000;
  padding: 10px;
  top: 0px;
  left: 0px;
  right: 0px;
  bottom: 0px;
  pointer-events: none;
}
.alert {
          animation: fadeOut 2700ms ease-in 0s 1 forwards;
  -webkit-animation: fadeOut 2700ms ease-in 0s 1 forwards;
     -moz-animation: fadeOut 2700ms ease-in 0s 1 forwards;
  width: 250px;
  float: right;
  clear: both;
  margin-bottom: 5px;
  pointer-events: auto;
}
client/stylesheets/style.css

คอมมิท 2-3

Re-arranged file structure.

ใช้ CoffeeScript ได้มั้ย

ในหนังสือเล่มนี้ เราจะเขียนแอพกันด้วยจาวาสคริปต์ แต่ถ้าคุณต้องการใช้ CoffeeScript ก็สามารถทำได้โดยเพิ่มแพ็คเกจ CoffeeScript เข้าไปในแอพตามคำสั่งข้างล่างนี้ จากนั้นคุณก็ลุยต่อได้เลย

meteor add coffeescript

การดีพลอยขึ้นเว็บ

Sidebar 2.5

คนบางคนชอบที่จะทำงานเงียบๆ จนกว่าโครงการจะเสร็จสมบูรณ์ ในขณะที่คนอื่นๆ อดใจรอไม่ไหวที่จะแสดงให้โลกรับรู้ในทันทีที่ทำได้

ถ้าคุณเป็นคนจำพวกแรก และในตอนนี้ต้องการแค่สร้างแอพบนเครื่องตัวเอง ก็สามารถข้ามบทนี้ไปได้ แต่ถ้าคุณยอมเสียเวลาซักนิดเพื่อเรียนรู้วิธีการดีพลอยแอพ Meteor ของคุณให้ขึ้นไปทำงานบนเว็บ เราก็พร้อมจะสอนให้

โดยเราจะมาเรียนวิธีการดีพลอยแอพ Meteor หลายๆแบบ ซึ่งคุณสามารถนำแบบไหนไปใช้ก็ได้ในระหว่างที่คุณกำลังสร้างแอพ ไม่ว่าคุณกำลังสร้างแอพ Microscope หรือแอพ Meteor ตัวอื่นๆ ถ้าพร้อมแล้วก็มาเริ่มกันเลย!

รู้จักกับบทแทรก

บทเรียนนี้เป็น บทแทรก (sidebar) ซึ่งเจาะลึกลงไปในเรื่องของ Meteor มากขึ้นและเนื้อหาก็แยกออกมาจากเรื่องอื่นในหนังสือ

ดังนั้นถ้าคุณต้องการจะสร้างแอพ Microscope คุณก็สามารถข้ามบทนี้ไปก่อนได้ แล้วค่อยกลับมาอ่านอีกครั้ง

ดีพลอยไปที่ Meteor

การดีพลอยไปที่โดเมนย่อยของ Meteor (เช่น http://myapp.meteor.com) เป็นทางเลือกที่ง่ายที่สุด และเป็นวิธีแรกที่เราจะลองกัน วิธีนี้ช่วยให้คุณสามารถโชว์แอพให้คนอื่นๆเห็นได้ตั้งแต่ตอนเริ่มทำ หรือใช้เพื่อทำเป็นเซิร์ฟเวอร์ทดสอบชั่วคราวก่อนนำไปใช้งานจริงก็ได้

ดีพลอยไปที่ Meteor นั้นง่ายมาก แค่เปิดเทอร์มินอล แล้วไปที่โฟลเดอร์แอพ Meteor ของคุณ จากนั้นก็พิมพ์ :

meteor deploy myapp.meteor.com

โดยคุณต้องเปลี่ยนชื่อ “myapp” ด้วยชื่อที่คุณเลือกไว้ และยังไม่มีใครเอาไปใช้

ถ้าคุณทำการดีพลอยเป็นครั้งแรก คุณจะถูกขอให้สร้างบัญชีผู้ใช้ที่ Meteor เมื่อคุณดำเนินการเสร็จ ไม่กี่วินาทีหลังจากนั้นคุณก็สามารถเรียกใช้แอพของคุณได้ที่ http://myapp.meteor.com

ทั้งนี้คุณสามารถใช้ หน้าเอกสารหลักของ Meteor เพื่อค้นหาข้อมูลเพิ่มเติม เช่น การเข้าถึงฐานข้อมูลที่แอพคุณกำลังใช้ หรือการใช้ชื่อโดเมนอื่นกับแอพของคุณ

ดีพลอยไปที่ Modulus

Modulus เป็นทางเลือกที่เยี่ยมอันหนึ่งในการดีพลอยแอพ Node.js ทั้งยังเป็นหนึ่งในไม่กี่ตัวซึ่งเป็น Paas (platform-as-a-service) ที่รองรับการใช้งาน Meteor อย่างเป็นทางการ และก็มีบางคนรันแอพที่ใช้งานจริงอยู่บนนั้นด้วย

ซึ่งคุณสามารถศึกษาข้อมูลเพิ่มเติมเกี่ยวกับ Modulus โดยอ่านเพิ่มเติมได้จาก คู่มือการดีพลอยแอพ Meteor ที่เว็บ Modulus

ดีพลอยด้วย Meteor Up

ถึงแม้ว่าจะมีบริการคลาวด์เซิร์ฟเวอร์ใหม่ๆเกิดขึ้นทุกวัน มันก็มักจะมาพร้อมกับปัญหาและข้อจำกัดอยู่เสมอ ดังนั้น ณ วันนี้ การดีพลอยแอพไปที่เซิร์ฟเวอร์ของคุณเองก็ดูจะเป็นวิธีดีที่สุดที่จะนำแอพ Meteor มาใช้งานจริง สิ่งเดียวที่จะเป็นปัญหาก็คือ การดีพลอยด้วยตัวคุณเองนั้นไม่ใช่เรื่องง่าย โดยเฉพาะเมื่อคุณต้องการดีพลอยขึ้นไปใช้กับงานจริง

Meteor Up (หรือเรียกสั้นๆว่า mup) เป็นความพยายามที่จะแก้ปัญหานั้น โดยโปรแกรมเสริมแบบคอมมานด์ไลน์นี้จะช่วยคุณติดตั้งและดีพลอยแอพได้ ดังนั้นเราจะมาดูวิธีการดีพลอยแอพ Microscope ด้วย Meteor Up กัน

ก่อนอื่นเราจำเป็นต้องมีเซิร์ฟเวอร์ที่ใช้ดีพลอย เราขอแนะนำที่ Digital Ocean ซึ่งเริ่มที่แค่ $5 ต่อเดือน หรือที่ AWS ที่มี Micro instances ให้ใช้ฟรื (เมื่อใช้ไปซักพักคุณก็จะพบข้อจำกัดเรื่องการขยายระบบ แต่มันก็เพียงพอที่คุณจะทดสอบการใช้ Meteor Up)

ทั้งนี้ไม่ว่าคุณจะใช้บริการที่ไหน สิ่งที่คุณได้มาก็คือ หมายเลขไอพีของเซิร์ฟเวอร์, ชื่อล็อกอิน (ปกติจะเป็น root หรือ ubuntu) และรหัสผ่าน ให้เก็บข้อมูลพวกนี้ไว้ในที่ปลอดภัย เพราะเราจำเป็นต้องใช้ในไม่ช้านี้ !

เตรียมการใช้ Meteor Up

แรกสุด เราจำเป็นต้องติดตั้ง Meteor Up ด้วย npm ดังนี้

npm install -g mup

จากนั้นเราจะสร้างโฟลเดอร์พิเศษแยกออกมาต่างหากเพื่อเก็บค่าการติดตั้งของการดีพลอยแต่ละตัว ที่เราต้องแยกโฟลเดอร์ออกมานั้นมีเหตุผลสองอย่าง อันดับแรกก็คือ มันจะดีที่สุดถ้าเราแยกข้อมูลสำคัญส่วนตัวออกมาจาก Git repo โดยเฉพาะเมื่อคุณกำลังทำงานกับโค้ดที่ใครก็เข้าถึงได้

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

ตอนนี้เราก็มาสร้างโปรเจกต์ Meteor Up กัน โดยเราจะสร้างโฟลเดอร์ขึ้นใหม่ และเตรียมการดีพลอยด้วยคำสั่งดังนี้

mkdir ~/microscope-deploy
cd ~/microscope-deploy
mup init

แบ่งปันด้วย Dropbox

วิธีการอันยอดเยี่ยมที่จะช่วยให้คุณและทีมคุณใช้ข้อมูลการดีพลอยเหมือนกันได้ก็คือ สร้างโฟลเดอร์เก็บค่าการติดตั้งของ Meteor Up ไว้ภายในโฟลเดอร์ Dropbox หรือบริการอื่นๆที่คล้ายคลึงกัน

การตั้งค่าให้ Meteor Up

ในขณะที่กำลังเตรียมการดีพลอยในโปรเจกต์ใหม่นั้น Meteor Up จะสร้างไฟล์ขึ้นมาสองไฟล์ให้คุณคือ mup.json และ settings.json

mup.json จะเก็บค่าที่เกี่ยวข้องกับการดีพลอยไว้ทั้งหมด ส่วน settings.json จะเก็บค่าที่เกี่ยวข้องกับแอพ เช่น โทเคนของ OAuth , โทเคน analytic และอื่นๆ

ขั้นตอนต่อไปคือการตั้งค่า mup.json ของคุณ โดยที่คุณต้องทำทั้งหมดคือ เติมข้อมูลลงไปในไฟล์ตั้งต้น mup.json ที่สร้างจาก mup init ดังนี้

{
  //server authentication info
  "servers": [{
    "host": "hostname",
    "username": "root",
    "password": "password"
    //or pem file (ssh based authentication)
    //"pem": "~/.ssh/id_rsa"
  }],

  //install MongoDB in the server
  "setupMongo": true,

  //location of app (local directory)
  "app": "/path/to/the/app",

  //configure environmental
  "env": {
    "ROOT_URL": "http://supersite.com"
  }
}
mup.json

ตอนนี้เราจะมาดูค่าแต่ละตัวกัน

ข้อมูลการพิสูจน์ตัวตนบนเซิร์ฟเวอร์ (server authentication info)

คุณจะสังเกตุได้ว่า Meteor Up รองรับทั้งการพิสูจน์ตัวตนด้วยรหัสผ่าน และการใช้คีย์ส่วนตัว (PEM) ดังนั้นมันจึงใช้งานได้กับแทบทุกบริการบนคลาวด์

สิ่งสำคัญ ถ้าคุณใช้การพิสูจน์ตัวตนด้วยรหัสผ่าน คุณต้องมั่นใจว่าได้ติดตั้งโปรแกรม sshpass ไว้แล้วนะ (ดูวิธีการได้ที่นี้)

ข้อมูลการติดตั้ง MongoDB (install MongoDB in the server)

ต่อมาคือการตั้งค่าฐานข้อมูล MongoDB ให้แอพคุณ เราขอแนะนำให้ใช้ Compose หรือบริการ MongoDB บนคลาวด์เจ้าไหนก็ได้ เนื่องจากเราจะได้ทั้งการช่วยเหลือแบบมืออาชีพและเครื่องมือจัดการฐานข้อมูลที่ดีกว่า

และถ้าคุณเลือกที่จะใช้ Compose ก็ให้ตั้งค่า setupMongo เป็น false และเพิ่มตัวแปรระบบ MONGO_URLเข้าไปในบล็อก env ของไฟล์ mup.json แต่ถ้าคุณเลือกให้ Meteor Up ติดตั้ง MongoDB คุณก็แค่ตั้งค่า setupMongo เป็น true แค่นี้ Meteor Up ก็จะจัดการส่วนที่เหลือให้เอง

พาธของแอพ Meteor (location of app)

เนื่องจากข้อมูลค่าการติดตั้งของ Meteor Up อยู่คนละโฟลเดอร์กับแอพ เราจึงจำเป็นต้องบอกให้ Meteor Up รู้ว่าแอพอยู่ที่ไหนด้วยการตั้งค่า app คุณก็แค่ป้อนค่าพาธของแอพที่อยู่บนเครื่องคุณเข้าไป โดยคุณสามารถหาค่าพาธนี้ได้โดยเปิดโปรแกรมเทอร์มินอล แล้วไปที่โฟลเดอร์แอพ จากนั้นก็ป้อนคำสั่ง pwd คุณก็จะได้ค่าพาธของแอพออกมา

ตัวแปรระบบ (configure environmental)

คุณสามารถที่จะกำหนดค่าตัวแปรระบบของแอพคุณทั้งหมด เช่น ROOT_URL, MAIL_URL, MONGO_URL และอื่นๆ ได้ภายในบล็อก env

Setting Up and Deploying

ก่อนที่เราจะสั่งดีพลอย เราก็ต้องติดตั้งเซิร์ฟเวอร์ให้พร้อมสำหรับการรันแอพ Meteor ซึ่ง Meteor Up ได้ทำให้ขั้นตอนนี้ง่ายเหลือเพียงแค่คำสั่งเดียว!

mup setup

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

mup deploy

ซึ่งจะทำการผนวกแอพ Meteor ของคุณเข้าด้วยกัน แล้วดีพลอยไปที่เซิร์ฟเวอร์ที่เราเพิ่งติดตั้งไว้

การแสดงล็อก

ล็อกการใช้งานเป็นสิ่งสำคัญที่จำเป็น และ Meteor Up ก็ได้เตรียมวิธีการง่ายๆให้เราจัดการมันโดยการจำลองคำสั่ง tail -f ด้วยคำสั่งนี้

mup logs -f

ถึงตรงนี้ เราก็ได้เห็นภาพรวมการใช้งาน Meteor Up กันแล้ว ถ้าคุณต้องการข้อมูลมากกว่านี้ เราแนะนำให้คุณไปที่ หน้า GitHub ของ Meteor Up

ทั้งหมดนี้ก็คือ สามวิธีที่ใช้ในการดีพลอยแอพ Meteor ซึ่งน่าจะเพียงพอสำหรับการใช้งานทั่วๆไป แต่เราก็รู้ว่าอาจมีคุณบางคนที่อยากได้วิธีการติดตั้งเซิร์ฟเวอร์ Meteor แบบเบ็ดเสร็จด้วยตัวคุณเอง ซึ่งวิธีการแบบนั้นเราขอเก็บเอาไว้ก่อน … หรืออาจจะเป็นในหนังสืออีกเล่ม!

เทมเพลต

3

เพื่อให้สร้างแอพได้ง่ายขึ้น เราจะเริ่มด้วยการสร้างเปลือกนอกของแอพขึ้นมาก่อนด้วย HTML แล้วค่อยๆ เพิ่มจาวาสคริปต์เข้าไปข้างในจนแอพสามารถทำงานได้ หรือที่เรียกว่า วิธีการแบบนอกเข้าใน (outside-in )

ซึ่งในบทนี้ เราจะแก้ไขและสร้างไฟล์เฉพาะที่อยู่ในโฟลเดอร์ /client เท่านั้น

ขั้นแรกให้คุณเปิดไฟล์ main.html ที่สร้างไว้แล้วในโฟลเดอร์ /client แล้วป้อนโค้ดต่อไปนี้ลงไป :

<head>
  <title>Microscope</title>
</head>
<body>
  <div class="container">
    <header class="navbar navbar-default" role="navigation"> 
      <div class="navbar-header">
        <a class="navbar-brand" href="/">Microscope</a>
      </div>
    </header>
    <div id="main">
      {{> postsList}}
    </div>
  </div>
</body>
client/main.html

โดยเราจะใช้ไฟล์นี้เป็นเทมเพลตหลักของแอพ และโค้ดที่คุณเห็นในไฟล์ก็เป็น HTML เกือบทั้งหมด ยกเว้นตรงตำแหน่ง {{> postList}} ที่ Meteor จะนำเนื้อหาข้อมูลซึ่งได้จากเทมเพลต postsList แทรกเข้าไปแทน สิ่งที่เราจะทำต่อไปก็คือ สร้างเทมเพลทเพิ่มขึ้นอีกสองสามตัว

เทมเพลทของ Meteor

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

เริ่มกันต่อโดยการสร้างโฟลเดอร์ /templates ข้างใน /client เพื่อไว้เก็บเทมเพลททั้งหมด และสร้างโฟลเดอร์ย่อย /posts ไว้ใน /templates อีกชั้น เพื่อแยกเก็บเทมเพลทต่างๆที่เกี่ยวกับข่าวที่โพสท์

การค้นหาไฟล์

Meteor เก่งในเรื่องการค้นหาไฟล์ ไม่ว่าคุณจะสร้างไฟล์ไว้ที่ไหนในโฟลเดอร์ /client มันก็จะค้นหาจนเจอและจัดการคอมไพล์ได้อย่างถูกต้อง สิ่งที่เราได้คือเราไม่ต้องคอยผูกไฟล์เข้าไปในแอพอยู่ตลอด ไม่ว่าจะเป็นไฟล์จาวาสคริปต์หรือสไตล์ชีต (CSS)

ยิ่งกว่านั้น คุณยังสามารถที่จะแบ่งโค้ดออกเป็นหลายๆ ไฟล์ในโฟลเดอร์เดียว หรือรวมโค้ดทั้งหมดไว้ในไฟล์เดียวกันก็ได้ ซึ่งไม่ว่าจะทำแบบไหน Meteor ก็จะคอมไพล์ทุกไฟล์ที่พบให้เป็นไฟล์ที่ย่อขนาดเพื่อเอาไว้ใช้งานเพียงไฟล์เดียว ดังนั้นเพื่อให้สะดวกในการจัดการ เราจะแยกไฟล์ตามโฟลเดอร์ในแบบที่เราทำมาแล้วต่อไป

ตอนนี้เราก็พร้อมจะสร้างเทมเพลทที่สองแล้ว ให้คุณสร้าง posts_list.html ในโฟลเดอร์ client/templates/posts ด้วยโค้ดต่อไปนี้

<template name="postsList">
  <div class="posts page">
    {{#each posts}}
      {{> postItem}}
    {{/each}}
  </div>
</template>
client/templates/posts/posts_list.html

แล้วสร้าง post_item.html อีกตัวตามนี้

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
    </div>
  </div>
</template>
client/templates/posts/post_item.html

ให้สังเกตุตรงแอททริบิวท์ name="postsList" ในแท็ก template ซึ่งเป็นชื่อที่ Meteor ใช้เพื่อเชื่อมโยงเทมเพลทต่างๆเข้าด้วยกัน (โดยไม่สนใจชื่อไฟล์)

และแล้วก็ถึงเวลาที่เราจะมาทำความรู้จักกับระบบเทมเพลทของ Meteor ที่เรียกว่า Spacebars ซึ่งจริงๆแล้วก็คือ HTML ที่มีส่วนขยายเพิ่มสามส่วน ได้แก่ ตัวแทนที่ (inclusions หรือ partials), นิพจน์ (expressions) และ บล็อกตัวช่วย (block helpers)

ตัวแทนที่ มีรูปแบบเป็น {{> templateName}} ทำหน้าที่บอกให้ Meteor นำเทมเพลทตามชื่อที่ระบุมาแทนที่ (ในโค้ดของเราคือ postItem)

นิพจน์ เช่น {{title}} เป็นได้ทั้งคุณสมบัติของอ็อบเจกต์ตัวที่กำลังใช้งาน หรือค่าที่คืนกลับมาจากตัวช่วยเทมเพลท ที่สร้างไว้ในตัวจัดการของเทมเพลทปัจจุบัน ดังจะได้อธิบายในลำดับต่อไป

บล็อกตัวช่วย เป็นแท็กพิเศษที่ใช้ควบคุมลำดับการทำงานของเทมเพลท เช่น {{#each}}...{{/each}} หรือ {{#if}}...{{/if}}

ต้องการข้อมูลเพิ่มเติม

คุณสามารถค้นหาข้อมูลเพิ่มเติมเกี่ยวกับ Spacebars ได้ที่ หน้าคู่มือการใช้งาน Spacebars

รู้มาถึงตรงนี้ เราก็พอจะทำความเข้าใจกับสิ่งที่เกิดขึ้นได้ดังนี้

สิ่งที่เกิดขึ้นในเทมเพลท postsList ก็คือ บล็อกตัวช่วย {{#each}}..{{/each}} จะทำงานซ้ำเป็นจำนวนครั้งเท่ากับจำนวนข้อมูลที่อยู่ในอ็อบเจกต์ posts โดยในแต่ละรอบของการทำงาน เนื้อหาจากเทมเพลท postItem จะถูกใส่เพิ่มเข้าไปในเทมเพลทหลัก

แล้วอ็อบเจกต์ posts ได้มาจากไหน คำตอบคือ ได้มาจาก ตัวช่วยเทมเพลท ซึ่งทำหน้าที่คล้ายกับถังข้อมูลที่เตรียมไว้ให้เทมเพลทเรียกใช้

ส่วนในเทมเพลท postItem ก็ไม่มีอะไรซับซ้อน มันใช้แค่ 3 นิพจน์ คือ {{url}} และ {{title}} ที่ดึงค่ามาจากข่าวที่โพสท์ และ {{domain}} ที่ได้ค่ามาจากตัวช่วยเทมเพลท

ตัวช่วยเทมเพลท

ถึงตรงนี้เราก็ได้ลองใช้ Spacebars กันมาบ้างแล้ว อันที่จริงมันก็คือ HTML บวกกับแท็กพิเศษอีกนิดหน่อย ที่แตกต่างจากภาษาอื่น เช่น PHP (หรือแม้กระทั่งหน้า HTML เองที่สามารถใส่จาวาสคริปต์เข้าไปได้) ก็คือ Meteor แยกส่วนที่เป็นโค้ดคำสั่งออกจากเทมเพลท โดยตัวเทมเพลทเองทำงานได้แค่คำสั่งพื้นฐานเท่านั้น

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

มองอีกมุมหนึ่ง เทมเพลทจะทำหน้าที่แค่แสดงหรือทำงานซ้ำๆ ตามค่าตัวแปรที่ได้มา ส่วนตัวช่วยจะทำหน้าที่กำหนดค่าให้ตัวแปรก่อนที่เทมเพลทจะนำไปใช้

มีคอนโทรลเลอร์ใน Meteor บ้างมั้ย

เราอาจมองว่าตัวช่วยเทมเพลท เป็นเหมือนคอนโทรลเลอร์ แต่เอาเข้าจริงแล้วคอนโทรลเลอร์ ในมุมมองของระบบ MVC จะทำหน้าที่บางอย่างแตกต่างออกไป

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

เพื่อให้เข้าใจง่าย เราจะตั้งชื่อไฟล์ที่เก็บโค้ดตัวช่วยเทมเพลท ตามชื่อเทมเพลท แต่เปลี่ยนนามสกุลเป็น .js แทน โดยเริ่มด้วยการสร้างไฟล์ posts_list.js ใน /client/templates/posts แล้วก็ใส่โค้ดตัวช่วยเทมเพลทตัวแรกของเราเข้าไป ดังนี้

var postsData = [
  {
    title: 'Introducing Telescope',
    url: 'http://sachagreif.com/introducing-telescope/'
  }, 
  {
    title: 'Meteor',
    url: 'http://meteor.com'
  }, 
  {
    title: 'The Meteor Book',
    url: 'http://themeteorbook.com'
  }
];
Template.postsList.helpers({
  posts: postsData
});
client/templates/posts/posts_list.js

ถ้าคุณทำถูก คุณควรจะเห็นหน้าจอแอพคล้ายๆแบบนี้

Our first templates with static data
Our first templates with static data

สิ่งที่เราทำไปมีสองอย่างคือ เราได้สร้างข้อมูลตัวอย่างในอาร์เรย์ postsData ซึ่งที่จริงควรจะดึงมาจากฐานข้อมูล แต่เพราะเรายังไม่รู้ว่าจะทำยังไง (เราจะสอนคุณในบทถัดไป !) เราก็เลยหลอกแอพด้วยการใช้ข้อมูลที่เราสมมุติขึ้นแทน

และเราได้สร้างตัวช่วยเทมเพลทชื่อ posts ที่จะส่งคืนค่าอาร์เรย์ postsData โดยใช้ฟังก์ชัน Template.postsList.helpers() ของ Meteor

ถ้าคุณยังจำได้ เรามีการเรียกใช้ตัวช่วย posts ในเทมเพลท postsList แบบนี้

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}
  </div>
</template>
client/templates/posts/posts_list.html

การสร้างตัวช่วยเทมเพลท posts ทำให้เราสามารถเรียกใช้มันได้จากเทมเพลทของเรา โดยเทมเพลทจะทำงานซ้ำตามจำนวนข้อมูลในอาร์เรย์ postsData และส่งต่อข้อมูลแต่ละตัวนั้นให้กับเทมเพลท postItem ทำงานต่อไป

คอมมิท 3-1

Added basic posts list template and static data.

ตัวช่วย domain

ก็คล้ายๆกับที่เราทำมาแล้ว เราก็สร้างไฟล์ post_item.js เพื่อเก็บโค้ดคำสั่งของเทมเพลท ดังนี้

Template.postItem.helpers({
  domain: function() {
    var a = document.createElement('a');
    a.href = this.url;
    return a.hostname;
  }
});
client/templates/posts/post_item.js

จะเห็นว่าค่าที่ส่งคืนมาจากตัวช่วย domain ไม่ใช่อาร์เรย์แต่เป็นฟังก์ชัน ซึ่งเป็นวิธีการเขียนโค้ดแบบที่นิยมใช้กันมาก และใช้ได้ดีกว่าแบบที่เราเขียนก่อนหน้านี้

Displaying domains for each links.
Displaying domains for each links.

ตัวช่วย domain นี้ จะรับค่า URL และส่งคืนค่าเป็นชื่อโดเมนด้วยการใช้คุณสมบัติพิเศษบางอย่างของจาวาสคริปต์ แต่ว่ามันได้ URL มาจากไหนกัน

เพื่อที่จะตอบปัญหานี้ เราต้องย้อนกลับไปดูที่เทมเพลท posts_list.html ตรงบล็อคตัวช่วย {{#each}} ที่ไม่เพียงทำงานซ้ำตามข้อมูลในอาร์เรย์ มันยังทำการ กำหนดค่า this ภายในบล็อกให้มีค่าเป็นอ็อบเจกต์ที่ได้จากอาร์เรย์อีกด้วย

นั่นก็หมายความว่า ในแต่ละรอบการทำงานของแท็ก {{#each}} ข้อมูลข่าวที่โพสต์ไว้แต่ละตัว จะถูกใส่เข้าไปในตัวแปร this และส่งต่อไปจนถึงตัวจัดการเทมเพลทใน post_item.js ด้วย

ถึงตรงนี้เราก็รู้แล้วว่าทำไม this.url ถึงคืนค่ากลับมาเป็น URL ของข่าวนั้นได้ และยิ่งไปกว่านั้น ถ้าเราใช้ {{title}} และ {{url}} ในเทมเพลท post_item.html Meteor ก็รู้ว่าเราหมายถึง this.title และ this.url และส่งค่าที่ถูกต้องกลับมาให้

คอมมิท 3-2

Setup a `domain` helper on the `postItem`.

คุณสมบัติพิเศษของจาวาสคริปต์

แม้จะไม่เกี่ยวกับ Meteor แต่เราก็จะอธิบายเรื่อง “คุณสมบัติพิเศษของจาวาสคริปต์” ให้คุณรู้ โดยการทำงานของโค้ดที่ตัวช่วย domain นั้น เริ่มจากการสร้างแท็ก anchor (a) แบบว่างๆ ขึ้นมาหนึ่งตัว และเก็บค่าไว้ในตัวแปร

จากนั้นเราก็นำค่า URL ของข่าวที่โพสต์ (ได้จากอ็อบเจกต์ this) ใส่เข้าไปที่แอตทริบิวต์ href ของตัวแปรที่เราสร้างไว้

สุดท้ายก็เรียกใช้ประโยชน์จากคุณสมบัติพิเศษ hostname ของแท็ก a เพื่อนำค่าโดเมนของ URL นั้นส่งคืนกลับมาให้เทมเพลทใช้งานต่อไป

ถ้าคุณทำตามเรามาตลอด คุณก็จะเห็นรายการหัวข้อข่าวที่โพสต์ปรากฎขึ้นในเบราว์เซอร์ โดยข้อมูลที่เห็นเป็นแค่ข้อมูลนิ่งๆ ยังไม่ได้ใช้ความสามารถแบบเรียลไทม์ของ Meteor แต่ไม่ต้องห่วงในบทต่อไปเราจะสอนให้คุณทำตรงนี้ได้ !

การรีโหลดแบบอัตโนมัติ

คุณอาจสังเกตุเห็นว่า เราไม่จำเป็นต้องรีโหลดหน้าเบราว์เซอร์เลยเมื่อคุณทำการเปลี่ยนแปลงโค้ด

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

การรีโหลดแบบอัตโนมัติของ Meteor ทำงานได้อย่างชาญฉลาด ทั้งยังเก็บสถานะของแอพคุณในระหว่างการรีเฟรชไว้ให้ด้วย !

การใช้งาน Git และ GitHub

Sidebar 3.5

GitHub คือ เว็บที่ให้บริการพื้นที่จัดเก็บโครงการโอเพ่นซอร์สด้วยระบบควบคุมเวอร์ชันแบบ Git โดยมีจุดประสงค์หลักคือ ทำให้การแบ่งปันและพัฒนาโครงการต่างๆด้วยกันเป็นไปได้ง่ายๆ ในบทแทรกนี้ เราจะมาดูวิธีการหลายๆอย่างที่คุณสามารถใช้ GitHub เพื่อทำตามที่หนังสือ Discover Meteor บอกได้

โดยบทแทรกนี้จะสมมุติว่าคุณไม่คุ้นเคยกับการใช้ Git หรือ GitHub มาก่อน แต่ถ้าคุณใช้มันได้คล่องแล้ว ก็ให้ผ่านไปที่บทถัดไปได้เลย

เมื่อถูกคอมมิท

การทำงานพื้นฐานของ Git ก็คือการ คอมมิท คุณสามารถมองได้ว่า คอมมิทก็คือภาพถ่ายโค้ดของโปรแกรมคุณในขณะหนึ่ง

แทนที่เราจะให้คุณเห็นโค้ดสำเร็จของแอพ Microscope ในครั้งเดียว เราก็ถ่ายภาพโค้ดของโปรแกรมไว้ทีละขั้นจนครบทุกขั้น ซึ่งคุณสามารถเข้าไปดูออนไลน์ได้ที่ GitHub

โดยตัวอย่างที่คุณเห็นนี้ก็คือ คอมมิทตัวสุดท้ายของบทเรียนก่อนหน้านี้

A Git commit as shown on GitHub.
A Git commit as shown on GitHub.

สิ่งที่คุณเห็นคือ “ดิฟ” (มาจาก “difference”) หรือ “ส่วนต่าง” ของไฟล์ post_item.js ซึ่งก็คือ การเปลี่ยนแปลงที่เกิดจากคอมมิทนี้ ในกรณีนี้เกิดจากที่เราสร้างไฟล์ post_item.js ขึ้นมาใหม่ ดังนั้นโค้ดทั้งหมดจึงถูกเน้นด้วยสีเขียว

ตอนนี้ลองมาเปรียบเทียบกับตัวอย่างจาก โค้ดในหนังสือหลังจากบทนี้

Modifying code.
Modifying code.

จะเห็นว่าตอนนี้ มีแค่เพียงบรรทัดที่ถูกแก้ไขเท่านั้นที่ถูกเน้นด้วยสีเขียว

และแน่นอนว่าบางครั้งคุณไม่ได้เพิ่มหรือแก้ไขโค้ด แต่ ลบมันออกไป

Deleting code.
Deleting code.

ที่เราได้เห็นก็คือ วิธีการใช้ GitHub อย่างแรก เพื่อตรวจดูว่ามีอะไรเปลี่ยนแปลงในโค้ดบ้าง

ดูโค้ดตอนที่คอมมิท

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

GitHub ก็ยอมให้เราทำแบบนั้นได้ โดยเมื่อคุณอยู่ที่หน้าคอมมิท ให้คลิ๊กที่ปุ่ม Browse code

The Browse code button.
The Browse code button.

คุณก็จะเข้าไปในพื้นที่เก็บไฟล์ ณ ตอนที่คอมมิทนั้นเพิ่งเกิดขึ้น

The repository at commit 3-2.
The repository at commit 3-2.

ถึงแม้ GitHub ไม่ได้แสดงให้เราเห็นว่าเรากำลังดูอยู่ที่คอมมิท แต่คุณก็สามารถที่จะเปรียบเทียบกับหน้า “ปกติ” และเห็นได้ว่าโครงสร้างไฟล์แตกต่างออกไป

The repository at commit 14-2.
The repository at commit 14-2.

เข้าถึงคอมมิทบนเครื่องคุณ

เราเพิ่งจะเห็นวิธีการดูคอมมิทของโค้ดทั้งหมดแบบออนไลน์ที่ GitHub แต่ถ้าเกิดเราต้องการทำแบบนั้นจากบนเครื่องเราเองจะทำได้มั๊ย อย่างเช่น คุณอาจต้องการรันแอพบนเครื่องด้วยโค้ดที่คอมมิทในเวลาใดเวลาหนึ่ง เพื่อดูว่าตอนนั้นมันทำงานอย่างไร

การจะทำแบบนี้ได้ ก้าวแรก (อย่างน้อยก็จากหนังสือเล่มนี้) เราต้องใช้โปรแกรมคำสั่ง git สำหรับมือใหม่ คุณต้องติดตั้ง Git ซะก่อน โดยเราจะ โคลน (clone) (หรือพูดง่ายๆคือ ดาวน์โหลดสำเนาโค้ดมาเก็บไว้ที่เครื่อง) จากพื้นที่เก็บแอพ Microscope

git clone https://github.com/DiscoverMeteor/Microscope.git github_microscope

ตัว github_microscope ที่ท้ายคำสั่งก็คือ ชื่อของโฟลเดอร์บนเครื่องที่คุณต้องการโคลนแอพลงมา ทั้งนี้สมมุติว่าคุณมีโฟลเดอร์ชื่อ microscope อยู่ก่อนแล้ว คุณก็ต้องใช้ชื่ออื่น (ซึ่งไม่จำเป็นต้องเหมือนกับชื่อของพื้นที่จัดเก็บบน GitHub)

ลอง cd เข้าไปในโฟลเดอร์ที่คุณโคลนมา เราจะได้ใช้คำสั่ง git กันดู

cd github_microscope

เมื่อเราโคลนพื้นที่จัดเก็บจาก GitHub เราก็ได้ดาวน์โหลดโค้ดของแอพ “ทั้งหมด” ลงมา และก็หมายความว่า เราอยู่ที่ตำแหน่งคอมมิทครั้งสุดท้าย

แต่ยังดีที่เรามีวิธีย้อนเวลากลับไป แล้ว “เช็คเอาท์” โค้ดจากคอมมิทไหนออกมาก็ได้ โดยไม่ส่งผลต่อคอมมิทอื่น ลองดูกันดีกว่า

git checkout chapter3-1
Note: checking out 'chapter3-1'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

  git checkout -b new_branch_name

HEAD is now at a004b56... Added basic posts list template and static data.

Git แจ้งให้เราทราบว่าตอนนี้เราอยู่ที่สถานะ “detached HEAD” ซึ่งหมายความว่า ตราบใดที่เราใช้ Git เราก็สามารถที่จะสำรวจคอมมิทในอดีตได้ แต่ไม่สามารถแก้ไขได้ คุณอาจเทียบมันได้กับ พ่อมดที่กำลังเพ่งสำรวจอดีตผ่านลูกแก้ววิเศษณ์อยู่

(ยังมีคำสั่งของ Git ที่ช่วยให้เรา แก้ไข คอมมิทในอดีตได้ ซึ่งก็เหมือนกับนักเดินทางข้ามเวลาได้ย้อนเวลากลับไปในอดีต และบังเอิญไปเหยียบผีเสื้อเข้า แต่จะว่าไปมันอยู่นอกเหนือเรื่องที่กำลังอธิบายไปแล้ว)

เหตุผลที่คุณสามารถพิมพ์แค่ chapter3-1 ได้ ก็เพราะเราได้ติดแท็กที่คอมมิทต่างๆของ Microscope ด้วยหมายเลขตำแหน่งในบทเรียน ถ้าไม่มีตรงนี้ คุณก็ต้องหา hash ของคอมมิท (หรือหมายเลขเฉพาะของคอมมิท) มาใช้แทน

ซึ่ง GitHub ก็ช่วยให้ชีวิตเราง่ายขึ้นอีกครั้ง คุณจะเห็น hash ของคอมมิทที่มุมขวาล่างของกล่องหัวเรื่องคอมมิท เหมือนที่แสดงในรูป

Finding a commit hash.
Finding a commit hash.

ตอนนี้เรามาลองใช้ hash แทนแท็กดู

git checkout c7af59e425cd4e17c20cf99e51c8cd78f82c9932
Previous HEAD position was a004b56... Added basic posts list template and static data.
HEAD is now at c7af59e... Augmented the postsList route to take a limit

และสุดท้าย ถ้าเราไม่ต้องการมองเข้าไปในลูกแก้ว และต้องการกลับมาปัจจุบัน เราก็แค่บอก Git ว่าเราต้องการเช็คเอาท์จาก master

git checkout master

สังเกตุด้วยว่าเราสามารถที่จะรันแอพด้วยคำสั่ง meteor ที่ตอนไหนก็ได้ แม้แต่ตอนที่อยู่ในสถานะ “detached HEAD” โดยคุณอาจจำเป็นต้องรัน meteor updateในตอนแรก ถ้า Meteor เตือนว่ามีแพ็คเกจขาดหายไป เพราะว่าโค้ดของแพ็คเกจไม่ได้ถูกรวมไว้ในพื้นที่จัดเก็บของ Git

ประวัติคอมมิท

ยังมีอีกกรณีที่เกิดขึ้น เช่น คุณกำลังดูไฟล์อยู่ตัวนึงและสังเกตุว่ามีการแก้ไขที่คุณไม่เคยเห็นมาก่อน ที่สำคัญคือคุณจำไม่ได้ว่า ตอนไหน ที่ไฟล์ถูกแก้ คุณอาจเปิดดูทีละคอมมิทจนกว่าจะพบตัวที่ต้องการก็ได้ แต่มันมีวิธีที่ง่ายกว่านั้น ซึ่งต้องขอบคุณความสามารถเรื่อง History ของ GitHub

ขั้นแรกให้เปิดไฟล์ในพื้นที่จัดเก็บของคุณบน GitHub ซักตัวนึง จากนั้นก็กดที่ปุ่ม “History”

GitHub's History button.
GitHub’s History button.

คุณก็จะเห็นรายการคอมมิททั้งหมดที่เกิดขึ้นกับไฟล์นั้น

Displaying a file's history.
Displaying a file’s history.

รู้นะว่าใครทำ

ก่อนจะจบ เรามาดูที่ Blame กัน

GitHub's Blame button.
GitHub’s Blame button.

มุมมองตรงนี้จะแสดงให้เราเห็นบรรทัดต่อบรรทัดเลยว่าใครทำอะไรกับไฟล์ และในคอมมิทไหน (อาจหมายถึง ควรจะว่าใครถ้ามันเกิดใช้การไม่ได้ขึ้นมา)

GitHub's Blame view.
GitHub’s Blame view.

ปัจจุบันนี้ Git ได้กลายเป็นเครื่องมือที่ซับซ้อนพอประมาณ เช่นเดียวกับ GitHub ดังนั้นเราก็ไม่ได้หวังว่าจะอธิบายทุกเรื่องได้ในบทเดียว ที่จริงเราแทบจะไม่ได้พูดถึงว่าเราจะทำอะไรกับมันได้บ้าง แต่เราก็หวังว่า เรื่องเล็กๆน้อยๆ นี้จะใช้ประโยชน์ได้บ้างตอนที่คุณทำตามเนื้อหาในหนังสือ

คอลเลกชั่น

4

ในบทที่ 1 เราได้เกริ่นถึงคุณสมบัติหนึ่งของ Meteor เรื่องการซิงโครไนซ์ข้อมูลอัตโนมัติระหว่างไคลเอนต์และเซิร์ฟเวอร์กันไปบ้างแล้ว

ตอนนี้ก็ถึงเวลาที่จะเข้าไปดูในรายละเอียดว่ามันทำงานอย่างไร และทำความเข้าใจการทำงานของ คอลเลกชั่น ใน Meteor ซึ่ีงเป็นเทคโนโลยีหลักที่ช่วยให้การทำงานนี้เกิดขึ้นได้

คอลเลกชั่นเป็นโครงสร้างข้อมูลแบบพิเศษที่ช่วยให้เราจัดเก็บข้อมูลลงฐานข้อมูล MongoDB บนเซิร์ฟเวอร์ และซิงโครไนซ์ข้อมูลแบบเรียลไทม์ระหว่างเซิร์ฟเวอร์กับเบราว์เซอร์ของผู้ใช้แต่ละคนที่เชื่อมต่อเข้ามา

สิ่งที่เราต้องการทำก็คือ จัดเก็บข่าวที่โพสต์ลงฐานข้อมูล และแชร์การใช้งานระหว่างผู้ใช้ภายในแอพ ดังนั้นเราจึงเริ่มต้นด้วยการสร้างคอลเลกชั่นชื่อ Posts ขึ้นเพื่อใช้เก็บข้อมูลนี้

เนื่องจากเรามักใช้คอลเลกชั่นเป็นศูนย์กลางของแอพ ดังนั้นเราจึงควรวางคอลเลกชั่นไว้ในโฟลเดอร์ lib เพื่อให้แน่ใจว่ามันถูกสร้างขึ้นตอนที่แอพเริ่มทำงานเสมอ โดยในแอพของเรานั้น ให้คุณสร้างโฟลเดอร์ย่อย collections/ ใน lib และสร้างไฟล์ posts.js ไว้ข้างใน ด้วยโค้ดต่อไปนี้ :

Posts = new Mongo.Collection('posts');
lib/collections/posts.js

ใช้ Var หรือไม่ใช้ Var กันแน่

ใน Meteor นั้น คำสั่ง var จะจำกัดการใช้งานของอ็อบเจกต์ไว้เฉพาะในไฟล์ที่เรียกใช้เท่านั้น ด้วยเหตุนี้เราจึงสร้างคอลเลกชั่น Posts โดยไม่มีคำสั่ง var นำหน้า เพื่อให้เราเรียกใช้มันได้จากทุกๆ ที่ในแอพนั่นเอง

การเก็บข้อมูล

เว็บแอพมีวิธีจัดเก็บข้อมูลพื้นฐานอยู่ 3 รูปแบบ ดังนี้

  • เก็บในหน่วยความจำเบราว์เซอร์ : ข้อมูลที่เก็บไว้ในหน่วยความจำเบราว์เซอร์อย่างเช่น ตัวแปรจาวาสคริปต์ จะใช้งานได้เฉพาะในแท็บที่จาวาสคริปต์นั้นทำงานอยู่ ไม่ได้ถูกเก็บอย่างถาวร และจะสูญหายไปเมื่อคุณปิดแท็บนั้น

  • เก็บในที่เก็บข้อมูลของเบราว์เซอร์ : เบราว์เซอร์นั้นสามารถที่จะเก็บข้อมูลให้นานขึ้นได้ด้วยการใช้คุกกี้ หรือที่เก็บข้อมูลส่วนตัว (Local Storage) แม้ว่าข้อมูลที่เก็บไว้นี้จะคงอยู่ในทุกๆ session ของเบราว์เซอร์ มันก็เข้าถึงได้จากผู้ใช้ปัจจุบันเท่านั้น (จากทุกๆ แท็บที่เปิดใช้) และก็ไม่ง่ายที่จะแชร์ข้อมูลนี้ให้ผู้ใช้คนอื่น

  • เก็บลงฐานข้อมูลบนเซิร์ฟเวอร์ : การเก็บลงฐานข้อมูลเป็นทางเลือกที่ดีที่สุด ถ้าคุณต้องการจัดเก็บข้อมูลแบบถาวร และให้ผู้ใช้หลายคนเข้าถึงข้อมูลได้ (แอพของ Meteor จะใช้ MongoDB เป็นฐานข้อมูลตั้งต้น)

Meteor นั้นใช้การเก็บข้อมูลทั้ง 3 แบบ และในบางครั้งยังทำการซิงโครไนซ์ข้อมูลจากที่หนึ่งไปอีกที่หนึ่งให้ด้วย (เดี๋ยวเราจะได้ดูตัวอย่างกัน) จากที่กล่าวมาจะเห็นได้ว่า ฐานข้อมูลยังคงเป็นแหล่งข้อมูล “หลัก” ที่ใช้เก็บข้อมูลของระบบกันโดยทั่วไป

ไคลเอนต์และเซิร์ฟเวอร์

โค้ดที่อยู่นอกโฟลเดอร์ client และ server จะทำงานทั้งในฝั่งไคลเอนต์และฝั่งเซิร์ฟเวอร์ ดังนั้นคอลเลกชั่น Posts จึงถูกเรียกใช้งานได้จากทั้งสองฝั่ง อย่างไรก็ตาม การทำงานของคอลเลกชั่นในแต่ละฝั่งนั้นจะมีความแตกต่างกันอยู่พอสมควร

ในฝั่งเซิร์ฟเวอร์ หน้าที่ของคอลเลกชั่น คือ ติดต่อกับฐานข้อมูล MongoDB อ่านและเขียนข้อมูลที่มีการเปลี่ยนแปลง หรือเปรียบได้กับไลบรารีของระบบฐานข้อมูลทั่วไปนั่นเอง

ส่วนในฝั่งไคลเอนต์ คอลเลกชั่น ก็คือ สำเนาของข้อมูล ย่อย ที่ได้จากคอลเลกชั่นในฐานข้อมูลหลัก โดยคอลเลกชั่นที่ไคลเอนต์จะถูกอัพเดทแบบเรียลไทม์ให้สอดคล้องกับข้อมูลย่อยนั้นอยู่เสมอและเป็นไปโดยที่เราแทบไม่รู้ตัว

คอนโซล กับ คอนโซล กับ คอนโซล

ในบทนี้ เราจะเริ่มใช้ คอนโซลของเบราว์เซอร์ ซึ่งเป็นคนละตัวกับ โปรแกรมเทอร์มินอล โปรแกรมเชลล์ของ Meteor หรือ โปรแกรมเชลล์ของ Mongo โดยเราได้ทำสรุปย่อของแต่ละตัวไว้แล้วดังนี้

โปรแกรมเทอร์มินอล

The Terminal
The Terminal
  • เรียกใช้ได้จากระบบปฏิบัติการ
  • โค้ดที่เซิร์ฟเวอร์ เมื่อใช้คำสั่ง console.log() จะแสดงผลออกที่นี่
  • พร้อมท์คำสั่ง : $
  • รู้จักกันในชื่อ : โปรแกรมเชลล์ หรือ แบชเชลล์ (Bash)

คอนโซลของเบราว์เซอร์

The Browser Console
The Browser Console
  • เรียกใช้ได้จากในเบราว์เซอร์ รันคำสั่งจาวาสคริปต์ได้
  • โค้ดที่ไคลเอนต์ เมื่อใช้คำสั่ง console.log() จะแสดงผลออกที่นี่
  • พร้อมท์คำสั่ง :
  • รู้จักกันในชื่อ : คอนโซลจาวาสคริปต์ คอนโซลนักพัฒนา (DevTools Console)

โปรแกรมเชลล์ของ Meteor

The Meteor Shell
The Meteor Shell
  • เรียกใช้ได้จากโปรแกรมเทอร์มินอล ด้วยคำสั่ง meteor shell
  • ช่วยให้คุณเข้าถึงโค้ดฝั่งเซิร์ฟเวอร์ของแอพคุณได้โดยตรง
  • พร้อมท์คำสั่ง : >

โปรแกรมเชลล์ของ Mongo

The Mongo Shell
The Mongo Shell
  • เรียกใช้ได้จากโปรแกรมเทอร์มินอล ด้วยคำสั่ง meteor mongo
  • ช่วยให้คุณเข้าถึงฐานข้อมูลของแอพคุณโดยตรง
  • พร้อมท์คำสั่ง : >
  • รู้จักกันในชื่อ : คอนโซล Mongo

จำไว้ว่า คุณไม่จำเป็นต้องป้อนอักษรพร้อมท์ ($, , หรือ >) รวมไปกับคำสั่ง และบรรทัดที่ไม่มีพร้อมท์คำสั่งนำหน้า ก็เป็นการแสดงผลลัพธ์ที่ได้จากคำสั่งก่อนหน้านั้น

คอลเลกชั่นที่เซิร์ฟเวอร์

ย้อนกลับไปตรงที่เราบอกว่า คอลเลกชั่นที่เซิร์ฟเวอร์ทำหน้าที่เป็นเหมือนกับไลบรารี่ของฐานข้อมูล Mongo นั่นก็คือ ในโค้ดฝั่งเซิร์ฟเวอร์นั้น คุณสามารถที่จะเขียนคำสั่ง เช่น Posts.insert() หรือ Posts.update() เพื่อทำการแก้ไขเปลี่ยนแปลงคอลเลกชั่น posts ที่เก็บไว้ใน Mongo ได้โดยตรง

เพื่อให้คุณเห็นข้อมูลในตัว Mongo ให้คุณเปิดโปรแกรมเทอร์มินอลขึ้นอีกตัว (พร้อมๆ กับตัวแรกที่คุณเปิดและรันคำสั่ง meteor ไว้แล้ว) แล้วไปที่โฟลเดอร์ของแอพคุณ จากนั้นให้รันคำสั่ง meteor mongo เพื่อเรียกใช้งานโปรแกรมเชลล์ของ Mongo ซึ่งคุณสามารถป้อนคำสั่งของ Mongo ได้ (คุณสามารถออกจากโปรแกรมด้วยปุ่ม ctrl+c) เช่น ลองเพิ่มโพสต์ข่าวเข้าไปใหม่ ด้วยคำสั่งนี้

meteor mongo

> db.posts.insert({title: "A new post"});

> db.posts.find();
{ "_id": ObjectId(".."), "title" : "A new post"};

The Mongo Shell

เรียกใช้ Mongo จาก Meteor.com

คุณควรรู้ว่า หลังจากที่คุณส่งแอพขึ้นไปรันที่ *.meteor.com แล้ว คุณยังสามารถเรียกใช้โปรแกรมเชลล์ของ Mongo เพื่อเข้าถึงฐานข้อมูลของแอพคุณที่รันอยู่ได้ ด้วยคำสั่ง meteor mongo myApp

และคุณก็ยังสามารถเรียกดูล็อกของแอพคุณได้ ด้วยคำสั่ง meteor logs myApp

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

คอลเลกชั่นที่ไคลเอนต์

การใช้คอลเลกชั่นที่ฝั่งไคลเอนต์มีอะไรที่มากกว่านั้น เมื่อคุณกำหนดให้ Posts = new Mongo.Collection('posts'); ที่ไคลเอนต์ ก็เท่ากับคุณได้สร้าง แคชข้อมูลในเบราว์เซอร์ จากข้อมูลจริงในคอลเลกชั่นของฐานข้อมูล Mongo โดย “แคช” ในความหมายของเราก็คือ คอลเลกชั่นของชุดข้อมูลย่อย ที่คุณสามารถเรียกมาใช้งานได้อย่างรวดเร็ว

เรื่องสำคัญที่ควรรู้เพื่อให้เข้าใจพื้นฐานการทำงานของ Meteor ก็คือ คอลเลกชั่นที่ไคลเอนต์ประกอบด้วยชุดข้อมูลย่อยของเอกสารที่เก็บไว้ในคอลเลกชั่นของ Mongo (เหตุผลก็คือ เราไม่ต้องการส่งข้อมูลทั้งหมดจากฐานข้อมูลไปไว้ที่ไคลเอนต์)

และเอกสารในชุดข้อมูลย่อยนี้ จะถูกเก็บไว้ในหน่วยความจำของเบราว์เซอร์ เพื่อให้เราเรียกใช้งานได้ทันที ไม่ต้องรอข้อมูลจากเซิร์ฟเวอร์ หรือรอฐานข้อมูลดึงข้อมูลมาให้อีก เมื่อคุณเรียกคำสั่ง Posts.find() ที่ไคลเอนต์ ข้อมูลที่เตรียมไว้ล่วงหน้าก็พร้อมใช้งานแล้ว

รู้จัก MiniMongo

เราเรียกโค้ดของ Meteor ที่จัดการฐานข้อมูล Mongo ในฝั่งไคลเอนต์ ว่า MiniMongo ซึ่งมันยังไม่สมบูรณ์แบบเท่าไหร่ โดยคุณอาจพบว่าไม่สามารถใช้คุณสมบัติบางอย่างของ Mongo กับ MiniMongo ได้ แต่คุณก็ไม่ต้องกังวล เพราะในหนังสือเล่มนี้เราใช้งานเฉพาะคุณสมบัติที่ทำงานได้ทั้งบน Mongo และ MiniMongo เท่านั้น

การสื่อสารระหว่างไคลเอนต์กับเซิร์ฟเวอร์

หัวใจของเรื่องทั้งหมด ก็คือ คอลเลกชั่นที่ไคลเอนต์ทำการซิงโครไนซ์ข้อมูลกับคอลเลกชั่นชื่อเดียวกัน (ในแอพเราคือ posts) บนเซิร์ฟเวอร์ได้อย่างไร

แทนที่จะอธิบายลงรายละเอียดในเรื่องนี้ เรามาลองทำไปด้วยกัน แล้วสังเกตุจากสิ่งที่เกิดขึ้น น่าจะทำให้เราเข้าใจได้ดีขึ้น

เริ่มจากเปิดเบราว์เซอร์ขึ้นสองหน้าต่าง แล้วเปิดใช้คอนโซลของเบราว์เซอร์ทั้งสองตัว จากนั้นให้เปิดเชลล์ของ Mongo จากคอมมานด์ไลน์

ตอนนี้เราจะลองค้นหาข่าวที่เราได้สร้างไว้ก่อนหน้า โดยป้อนคำสั่งต่อไปนี้เข้าไปในโปรแกรมที่ระบุ (ถ้าสังเกตุดีๆ จะเห็นว่าหน้าจอของแอพเรายังคงแสดงผลรายการข่าวที่โพสต์ไว้แค่สามตัว ตอนนี้ยังไม่ต้องสนใจอะไร ผ่านไปก่อน)

> db.posts.find();
{title: "A new post", _id: ObjectId("..")};
ในเชลล์ของ Mongo
 Posts.findOne();
{title: "A new post", _id: LocalCollection._ObjectID};
ในคอนโซลของเบราว์เซอร์ตัวแรก

ลองโพสต์ข่าวเข้าไปใหม่ โดยป้อนคำสั่งต่อไปนี้ลงในคอนโซลของเบราว์เซอร์

 Posts.find().count();
1
 Posts.insert({title: "A second post"});
'xxx'
 Posts.find().count();
2
ในคอนโซลของเบราว์เซอร์ตัวแรก

เป็นไปตามคาด ข่าวใหม่ที่เพิ่งโพสต์ได้ถูกสร้างเข้าไปในคอลเลกชั่นที่ไคลเอนต์แล้ว ตอนนี้ลองเช็คดูที่ Mongo ว่าเป็นยังไง

❯ db.posts.find();
{title: "A new post", _id: ObjectId("..")};
{title: "A second post", _id: 'yyy'};
ในเชลล์ของ Mongo

จากที่เห็น ข่าวที่เพิ่งโพสต์นี้ถูกส่งกลับไปเก็บที่ฐานข้อมูล Mongo ด้วย โดยที่เราไม่ต้องเขียนโค้ดจัดการตรงนี้แม้แต่บรรทัดเดียว (อันที่จริงเราเขียนไปแล้ว หนึ่ง บรรทัด: new Mongo.Collection('posts'))

ยิ่งไปกว่านั้น! ถ้าเราป้อนคำสั่งต่อไปนี้ในคอนโซลของเบราว์เซอร์ตัวที่สอง

 Posts.find().count();
2
ในคอนโซลของเบราว์เซอร์ตัวที่สอง

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

สิ่งที่เกิดขึ้นก็คือ คอลเลกชั่นฝั่งเซิร์ฟเวอร์ถูกแจ้งจากไคลเอนต์ว่ามีการโพสต์ข่าวใหม่เกิดขึ้น และรับหน้าที่กระจายข่าวที่โพสต์นี้ไปที่ฐานข้อมูล Mongo และส่งกลับไปยังคอลเลกชั่น post ตัวอื่นๆที่ได้เชื่อมต่อไว้

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

สร้างข้อมูลลงในฐานข้อมูล

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

สิ่งแรกที่เราจะทำคือ ใส่ข้อมูลลงในฐานข้อมูล โดยเราจะใช้ไฟล์ที่มีชุดข้อมูลตัวอย่างโหลดข้อมูลเข้าไปในคอลเลกชั่น Posts เมื่อเซิร์ฟเวอร์เริ่มทำงาน

ก่อนอื่นเราต้องทำให้แน่ใจว่าไม่มีอะไรในฐานข้อมูล ด้วยการเรียกใช้คำสั่ง meteor reset เพื่อลบฐานข้อมูลของคุณและรีเซ็ทแอพใหม่ ซึ่งคุณต้องระมัดระวังการใช้คำสั่งนี้ให้มาก หากคุณกำลังพัฒนาแอพจริงๆ อยู่

ปิดการทำงานของเซิร์ฟเวอร์ Meteor (ด้วยปุ่ม ctrl-c) และรันคำสั่งต่อไปนี้ ในคอมมานด์ไลน์

meteor reset

คำสั่งรีเซ็ตนี้จะลบทุกอย่างในฐานข้อมูล Mongo ซึ่งมีประโยชน์มากในตอนที่กำลังทำแอพ แล้วฐานข้อมูลของเราเกิดรวนขึ้นมา

ถึงเวลารันแอพเราอีกครั้งได้แล้ว

meteor

เมื่อฐานข้อมูลว่างแล้ว ตอนนี้เราก็จะใส่โค้ดที่จะทำการโหลดข่าวสามข่าวเข้าไปในคอลเลกชั่น ในตอนที่เซิร์ฟเวอร์เริ่มทำงาน โดยกำหนดเงื่อนไขว่า ให้โหลดเมื่อคอลเลกชั่น Posts ว่างอยู่เท่านั้น

if (Posts.find().count() === 0) {
  Posts.insert({
    title: 'Introducing Telescope',
    url: 'http://sachagreif.com/introducing-telescope/'
  });

  Posts.insert({
    title: 'Meteor',
    url: 'http://meteor.com'
  });

  Posts.insert({
    title: 'The Meteor Book',
    url: 'http://themeteorbook.com'
  });
}
server/fixtures.js

คอมมิท 4-2

Added data to the posts collection.

ที่เราวางไฟล์นี้ไว้ในโฟลเดอร์ server/ ก็เพราะไม่ต้องการให้เบราว์เซอร์โหลดไปใช้ โดยโค้ดชุดนี้จะทำงานทันทีเมื่อเซิร์ฟเวอร์เริ่มทำงาน และรันคำสั่ง insert เพื่อเพิ่มข่าวทั้งสามเข้าไปที่คอลเลกชั่น Posts ในฐานข้อมูล

ตอนนี้คุณก็รันเซิร์ฟเวอร์อีกครั้งด้วยคำสั่ง meteor ข่าวทั้งสามก็จะเข้าไปอยู่ในฐานข้อมูล

ข้อมูลแบบไดนามิก

ถ้าเราเปิดคอนโซลของเบราว์เซอร์ เราจะเห็นข่าวใหม่ทั้งสามถูกโหลดเข้ามาใน MiniMongo ด้วยคำสั่งนี้

 Posts.find().fetch();
Browser console

การจะเปลี่ยนข้อมูลข่าวเหล่านี้ให้เป็น HTML ได้นั้น เราจำเป็นต้องใช้ตัวช่วยเทมเพลท

ในบทที่ 3 เรารู้ว่า Meteor ยอมให้เราผูก เนื้อข้อมูล เข้ากับเทมเพลท Spacebars เพื่อสร้างหน้าเว็บของข้อมูลแบบง่ายๆ สิ่งที่เราจะทำตอนนี้ก็คล้ายๆกัน เราแค่เปลี่ยนข้อมูลนิ่งๆ ในอ็อบเจกต์ postsData ไปเป็นคอลเลกชั่นของข้อมูลที่แปรเปลี่ยนได้แทน

ด้วยการลบโค้ดส่วนของ postsData ออกไป แล้วก็แก้ไข posts_list.js ให้เป็นตามนี้

Template.postsList.helpers({
  posts: function() {
    return Posts.find();
  }
});
client/views/posts/posts_list.js

คอมมิท 4-3

Wired collection into `postsList` template.

ค้นหาแล้วดึงมาใช้

ใน Meteor คำสั่ง find() จะคืนค่าเคอร์เซอร์ ของแหล่งข้อมูลแบบรีแอคทีฟ (reactive data source) กลับมา เมื่อเราต้องการใช้ข้อมูลนั้น ก็ใช้คำสั่ง fetch() กับเคอร์เซอร์ เพื่อแปลงมันให้เป็นอาร์เรย์อีกที

ส่วนการทำงานจริงๆ ในแอพนั้น Meteor ก็ฉลาดพอที่จะรู้ว่า จะดึงข้อมูลจากเคอร์เซอร์มาใช้ได้อย่างไร โดยไม่ต้องแปลงให้เป็นอาร์เรย์เสียก่อน ด้วยเหตุนี้คุณถึงไม่เห็นคำสั่ง fetch() มากนักในโค้ดของ Meteor (และเราก็ไม่ได้ใช้มันในตัวอย่างข้างบนด้วย)

แทนที่เราจะส่งข้อมูลข่าวให้ตัวช่วยเทมเพลท posts ด้วยข้อมูลนิ่งๆ จากอาร์เรย์ เราก็จะส่งค่าเคอร์เซอร์ที่ได้มาไปให้แทน (เหตุที่หน้าจอดูไม่ต่างไปจากเดิม ก็เพราะเรายังคงใช้ข้อมูลเหมือนเดิมอยู่)

Using live data
Using live data

การทำงานทั้งหมดเกิดขึ้นเมื่อคอลเลกชั่นที่เซิร์ฟเวอร์ดึงข้อมูลข่าวมาจาก Mongo และส่งมันต่อให้กับคอลเลกชั่นที่ไคลเอนต์ จากนั้นตัวช่วยของ Spacebars ก็จะส่งต่อข้อมูลนี้ไปให้กับเทมเพลท บล็อกตัวช่วย {{#each}} ในเทมเพลท ก็จะทำงานซ้ำๆ เท่ากับจำนวนข่าวใน Posts และแสดงข้อมูลออกมาที่หน้าจอ

ตอนนี้เรามาลองโพสต์ข่าวใหม่เข้าไปด้วยคอนโซลของเบราว์เซอร์ ดังนี้

 Posts.insert({
  title: 'Meteor Docs',
  author: 'Tom Coleman',
  url: 'http://docs.meteor.com'
});
Browser console

เมื่อดูที่เบราว์เซอร์ คุณก็ควรจะเห็นแบบนี้

Adding posts via the console
Adding posts via the console

สิ่งที่คุณเห็นเป็นครั้งแรกนี้ คือ การทำงานแบบรีแอคทีฟ ซึ่งเกิดจากที่เราบอกให้ Spacebars ทำงานซ้ำๆ ตามจำนวนของข้อมูลในเคอร์เซอร์ที่ได้จากคำสั่ง Posts.find() เมื่อใดก็ตามที่มีการเปลี่ยนแปลงเกิดขึ้นที่เคอร์เซอร์ตัวนี้ ให้ทำการปรับหน้า HTML ด้วยวิธีที่ง่ายที่สุด เพื่อแสดงข้อมูลที่ถูกต้องออกทางหน้าจอทันที

มาสำรวจการเปลี่ยนแปลงของ DOM กัน

ในกรณีนี้, วิธีที่ง่ายที่สุดที่ Spacebars จะเปลี่ยนแปลง DOM ก็คือ การเพิ่มแท็ก <div class="post">...</div> เข้าไป ถ้าคุณอยากรู้ว่าเกิดอะไรขึ้นจริงๆ ให้เปิดตัวสำรวจ DOM ที่เบราว์เซอร์ และคลิ๊กเลือกที่ <div> ของข่าวไว้ตัวนึง

จากนั้นก็โพสต์ข่าวใหม่เพิ่มเข้าไปด้วยคอนโซลของเบราว์เซอร์ เมื่อคุณเปิดกลับไปที่แท็บของตัวสำรวจ DOM อีกครั้ง คุณจะเห็นว่ามี <div> ใหม่เกิดขึ้นตรงกับข่าวใหม่นั้น โดยข่าวตัวที่คุณคลิ๊กเลือกไว้ตอนแรกก็ยังถูกเลือกอยู่ ที่คุณเห็นอยู่นี้ก็คือ วิธีที่ Spacebars ใช้ปรับหน้า HTML ด้วยการเพิ่มแท็กเข้าไปใหม่โดยไม่ยุ่งกับแท็กเดิม

เชื่อมต่อกับคอลเลกชั่น : การเผยแพร่และบอกรับข้อมูล

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

เริ่มโดยเปิดโปรแกรมเทอร์มินอล และพิมพ์ :

meteor remove autopublish

ซึ่งจะส่งผลทันที ถ้าคุณดูที่เบราว์เซอร์ตอนนี้ จะเห็นว่ารายการข่าวทั้งหมดหายไปแล้ว! นั่นก็เพราะที่ผ่านมาเราพึ่งพาการทำงานของ autopublish ในการทำให้ข้อมูลคอลเลกชั่นของข่าวบนไคลเอนต์มีค่าเหมือนกันกับข่าวทั้งหมดที่โพสต์ไว้บนฐานข้อมูล

ในท้ายที่สุดแล้ว เราควรต้องดึงแค่รายการข่าวที่ผู้ใช้ต้องการเห็นมาเท่านั้น (อย่างเช่น แสดงเฉพาะหน้าที่เลือก) แต่ในตอนนี้เราแค่กำหนดให้คอลเลกชั่น Posts ส่งค่าข้อมูลทั้งหมดที่มีมาให้ก็พอ

ที่เราต้องทำคือ สร้างฟังก์ชัน publish() ที่จะส่งคืนค่าเคอร์เซอร์ของทุกรายการข่าวที่โพสต์ไว้กลับไป ดังนี้

Meteor.publish('posts', function() {
  return Posts.find();
});
server/publications.js

ในส่วนของไคลเอนต์ เราจำเป็นต้องระบุว่าจะเลือกรับเฉพาะคอลเลกชั่นของข้อมูลที่เราต้องการ โดยการเพิ่มโค้ดต่อไปนี้ที่ไฟล์ main.js

Meteor.subscribe('posts');
client/main.js

คอมมิท 4-4

Removed `autopublish` and set up a basic publication.

ถ้าเราดูที่เบราว์เซอร์อีกครั้ง จะเห็นว่ารายการข่าวของเรากลับมาแล้ว!

บทสรุป

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

การเผยแพร่และบอกรับข้อมูล

Sidebar 4.5

การเผยแพร่และบอกรับข้อมูล เป็นหนึ่งในพื้นฐานและแนวคิดหลักของ Meteor ซึ่งอาจจะทำให้คุณหัวหมุนได้เมื่อเริ่มต้นใช้มัน

มีความเข้าใจผิดๆเกี่ยวกับเรื่องนี้อยู่มาก เช่น เชื่อว่า Meteor ไม่มีความปลอดภัย หรือแอพ Meteor ไม่สามารถทำงานกับข้อมูลปริมาณมากๆได้

เหตุผลส่วนหนึ่งที่หลายๆคนสับสนกับเรื่องนี้ในตอนแรก ก็เพราะความมหัศจรรย์ที่ Meteor ทำให้เรา แม้ว่ามันจะก่อให้เกิดประโยชน์มากมาย แต่ก็ซ่อนการทำงานจริงๆไว้เบื้องหลัง (ถึงเรียกว่ามหัศจรรย์ไง) ดังนั้นในตอนนี้เราจะมาคลายความมหัศจรรย์นี้ออก แล้วลองทำความเข้าใจกับสิ่งที่เกิดขึ้นดู

วันเก่าๆ

ก่อนอื่นเราลองมองย้อนกลับไปในอดีตตอนปี 2011 ที่ยังไม่มี Meteor กันดู สมมุติว่าตอนนั้นคุณกำลังสร้างเว็บแอพด้วย Rails แบบง่ายๆ เมื่อมีคนเปิดเข้ามาที่เว็บไซต์คุณ ไคลเอนต์ (เช่น เบราว์เซอร์) จะส่งคำร้องขอข้อมูลเข้ามาที่แอพคุณซึ่งรันอยู่บนเซิร์ฟเวอร์

สิ่งแรกที่แอพทำคือ หาว่าผู้ใช้ต้องการเห็นข้อมูลอะไร อาจจะเป็นหน้า 12 ของผลการค้นหา, ประวัติย่อของแมรี่, 20 ทวิตล่าสุดของบ๊อบ หรืออื่นๆ คุณอาจเปรียบแอพเป็นเหมือนพนักงานในร้านหนังสือกำลังค้นที่ชั้นหนังสือหาหนังสือเรื่องที่คุณต้องการอยู่ก็ได้

เมื่อได้ข้อมูลที่ต้องการแล้ว สิ่งต่อมาที่แอพทำคือ แปลงข้อมูลนั้นให้เป็น HTML ที่เราอ่านได้ (หรือเป็น JSON ถ้าแอพเป็น API)

มองในมุมของร้านหนังสือ ก็เปรียบได้กับการหุ้มปกหนังสือที่คุณเพิ่งซื้อและใส่ถุงสวยๆ ซึ่งตรงนี้ก็คือ “View” ในโมเดล MVC ที่คนพูดถึงนั่นเอง

สุดท้าย แอพก็นำ HTML นั้นส่งกลับมาให้เบราว์เซอร์ และจบการทำงาน จากนั้นมันก็ว่างและอาจเพลินอยู่กับเบียร์ในขณะที่รอคำร้องขอข้อมูลถัดไปอยู่ก็ได้

วิธีของ Meteor

ตอนนี้ลองมาดูสิ่งที่ทำให้ Meteor มีความพิเศษต่างออกไป เราได้เห็นนวัตกรรมหลักที่ Meteor นำมาใช้คือ ในขณะที่แอพ Rails ทำงานอยู่ บนเซิร์ฟเวอร์ แต่แอพ Meteor ส่วนหนึ่งจะรัน ที่ไคลเอนต์ (ในเบราว์เซอร์) ด้วย

Pushing a subset of the database to the client.
Pushing a subset of the database to the client.

เปรียบได้กับพนักงานในร้านหนังสือ ที่ไม่เพียงแต่จะหาหนังสือให้คุณ แต่ยังตามคุณมาถึงบ้านและอ่านให้คุณฟังตอนกลางคืนด้วย (เรายอมรับว่ามันฟังดูน่ากลัวไปนิด)

สถาปัตยกรรมนี้ช่วยให้ Meteor ทำอะไรที่น่าสนใจได้หลายอย่าง ตัวเด่นๆก็คือที่ Meteor เรียกว่า ฐานข้อมูลอยู่ทั่วทุกที่ (database everywhere) พูดง่ายๆคือ Meteor จะนำชุดข้อมูลย่อยของฐานข้อมูลมา ทำสำเนา และส่งให้ ไคลเอนต์ ทุกตัว

วิธีการนี้ทำให้เกิดเรื่องที่เกี่ยวข้องสองเรื่องใหญ่ๆ คือ เรื่องแรก แทนที่จะส่งโค้ด HTML ไปให้ไคลเอนต์ Meteor จะส่ง ข้อมูลจริง หรือข้อมูลดิบ ไปให้ไคลเอนต์จัดการเอง (data on the wire) เรื่องที่สอง คุณสามารถที่จะ เข้าถึงและแม้กระทั่งแก้ไขข้อมูลนั้นได้ทันที โดยไม่ต้องรอให้ข้อมูลวนกลับไปที่เซิร์ฟเวอร์อีกรอบ (latency compensation)

การเผยแพร่ข้อมูล

ฐานข้อมูลของแอพสามารถมีข้อมูลได้ถึงหลายหมื่นรายการ บางรายการอาจเป็นข้อมูลส่วนตัวหรือส่งผลด้านอื่น ดังนั้นเพื่อความปลอดภัยและทำการปรับเปลี่ยนได้ในภายหลัง เราจึงไม่ควรส่งสำเนาข้อมูลทั้งหมดไปให้ไคลเอนต์

ดังนั้นที่เราต้องทำคือ หาวิธีบอก Meteor ว่า ชุดข้อมูลย่อย ตัวไหนที่สามารถส่งไปให้ไคลเอนต์ได้บ้าง โดยเราจะทำด้วยวิธี เผยแพร่ข้อมูล

ตอนนี้ถ้าเรากลับไปดูที่แอพ Microscope จะเห็นว่ารายการข่าวที่โพสต์ถูกเก็บไว้ในฐานข้อมูลแบบนี้

All the posts contained in our database.
All the posts contained in our database.

ถึงแม้ในตอนนี้ Microscope จะยังไม่มีคุณสมบัติที่จะระบุลงไปว่าข่าวตัวไหนใช้ภาษาที่ไม่เหมาะสม และเรายังเก็บข่าวพวกนี้ไว้ในฐานข้อมูล เราก็ไม่ควรให้ผู้ใช้เห็น (ก็คือไม่ต้องส่งไปให้ไคลเอนต์)

ในกรณีนี้ สิ่งแรกที่เราจะทำคือ บอก Meteor ว่าข่าวไหนที่เรา ต้องการ ส่งไปให้ไคลเอนต์ โดยเราจะบอก Meteor ให้ เผยแพร่ เฉพาะข่าวที่ผู้ใช้ควรเห็นเท่านั้น

Excluding flagged posts.
Excluding flagged posts.

ข้างล่างนี้คือ โค้ดที่เราต้องการ ซึ่งจะรันอยู่บนฝั่งเซิร์ฟเวอร์

// on the server
Meteor.publish('posts', function() {
  return Posts.find({flagged: false});
});

โค้ดนี้ทำให้เราแน่ใจได้ว่า ไม่มีทางเป็นไปได้ ที่ไคลเอนต์จะสามารถเข้าถึงข่าวที่ไม่เหมาะสมได้ และนี่ก็คือ วิธีการที่คุณใช้สร้างแอพ Meteor ให้ปลอดภัย โดยคุณต้องแน่ใจว่าได้เผยแพร่เฉพาะข้อมูลที่ผู้ใช้ปัจจุบันต้องการเท่านั้น

DDP

โดยพื้นฐานแล้ว คุณสามารถมองระบบเผยแพร่และรับข้อมูลนี้ เหมือนกับท่อที่ใช้ส่งข้อมูลจากคอลเลคชั่นบนเซิร์ฟเวอร์ (ต้นทาง) ไปที่คอลเลคชั่นที่ไคลเอนต์ (ปลายทาง)

โดยโปรโตคอลที่ใช้ในท่อนี้เรียกว่า DDP (ย่อมาจาก Distributed Data Protocol) ถ้าคุณต้องการเรียนรู้เพิ่มเติมในเรื่องนี้ คุณสามารถเข้าไปดู การบรรยายจากงานประชุมเรื่องเรียลไทม์ โดย Matt DeBergalis (หนึ่งในผู้ก่อตั้ง Meteor) หรือที่ สกรีนคาสต์นี้ โดย Matt DeBergalis ซึ่งจะช่วยให้คุณเข้าใจแนวคิดของเรื่องนี้ในรายละเอียดที่มากขึ้นอีกนิด

การบอกรับข้อมูล

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

ข้อมูลตัวไหนก็ตามที่คุณบอกรับไว้ จะถูก สำเนา เก็บไว้ที่ไคลเอนต์ด้วยการทำงานของ Minimongo โปรแกรม MongoDB ที่ทำงานในฝั่งไคลเอนต์

ตัวอย่างที่เห็น จะสมมุติว่าเรากำลังดูหน้าประวัติย่อของ Bob Smith อยู่ และต้องการจะดูเฉพาะข่าว ของเค้า เท่านั้น

Subscribing to Bob's posts will mirror them on the client.
Subscribing to Bob’s posts will mirror them on the client.

แรกสุด เราต้องปรับแก้ฟังก์ชันการเผยแพร่ข้อมูล ให้รับพารามิเตอร์หนึ่งตัว

// on the server
Meteor.publish('posts', function(author) {
  return Posts.find({flagged: false, author: author});
});

และเราก็ระบุค่าให้พารามิเตอร์นั้นตอนที่เรา บอกรับ ข้อมูลจากการเผยแพร่นั้น ในโค้ดฝั่งไคลเอนต์ของแอพเรา

// on the client
Meteor.subscribe('posts', 'bob-smith');

วิธีนี้คือ การที่คุณทำให้แอพ Meteor ปรับเปลี่ยนขนาดข้อมูลในฝั่งไคลเอนต์ได้ โดยแทนที่จะบอกรับข้อมูลที่เข้าถึงได้ ทั้งหมด ก็แค่เลือกเฉพาะส่วนที่ต้องการ คุณก็จะหลีกเลี่ยงปัญหาหน่วยความจำเบราว์เซอร์เต็มได้ ไม่ว่าฐานข้อมูลบนฝั่งเซิร์ฟเวอร์จะใหญ่แค่ไหนก็ตาม

การค้นหา

ตอนนี้สมมุติว่า ข่าวของ Bob มีอยู่ในหลายหมวดหมู่ (เช่น จาวาสคริปต์, รูบี้, ไพธอน) ซึ่งเราอาจจะต้องการโหลดมันทั้งหมดไว้ในหน่วยความจำ แต่ว่าในตอนนี้เราต้องการแสดงเพียงแค่ข่าวในหมวด “จาวาสคริปต์” เท่านั้น เราก็ต้องใช้ “การค้นหา” เข้ามาช่วย

Selecting a subset of documents on the client.
Selecting a subset of documents on the client.

ก็เหมือนกับที่เราทำบนโค้ดฝั่งเซิร์ฟเวอร์ เราจะใช้คำสั่ง Posts.find() มากรองเอาเฉพาะข้อมูลย่อยของเราเท่านั้น

// on the client
Template.posts.helpers({
  posts: function(){
    return Posts.find(author: 'bob-smith', category: 'JavaScript');
  }
});

ถึงตรงนี้เราก็มีความเข้าใจในบทบาทของการเผยแพร่และการบอกรับข้อมูลกันเป็นอย่างดีแล้ว ก็ได้เวลาที่เราจะมาเจาะลึกและสำรวจดูรูปแบบการทำงานสองสามอย่างที่ใช้กันทั่วไป

การเผยแพร่อัตโนมัติ (Autopublish)

ถ้าคุณสร้างแอพ Meteor ตั้งแต่เริ่มต้น (โดยใช้ meteor create) มันก็จะเปิดให้แพ็คเกจ autopublish ทำงานโดยอัตโนมัติ เพื่อให้เราเข้าใจตั้งแต่ต้น เราก็จะมาดูว่ามันทำงานอย่างไรกันแน่

เป้าหมายของ autopublish คือ ทำให้คุณเขียนโค้ดเพื่อสร้างแอพ Meteor ได้ง่ายๆ แะมันทำสิ่งนี้ด้วยการสำเนา ข้อมูลทั้งหมด อย่างอัตโนมัติจากเซิร์ฟเวอร์มาที่ไคลเอนต์ โดยจัดการเรื่องการเผยแพร่และบอกรับข้อมูลให้คุณทั้งหมด

Autopublish
Autopublish

แล้วมันทำงานอย่างไร คำอธิบายก็คือ สมมุติว่าคุณมีคอลเลคชั่นชื่อ 'posts' บนเซิร์ฟเวอร์ autopublish จะส่งข้อมูลทุกโพสต์ที่พบในคอลเลคชั่นโพสต์ของ Mongo ไปที่คอลเลคชั่นชื่อ 'posts' ที่ไคลเอนต์ (สมมุติว่ามีอย่างน้อยหนึ่งตัว)

ดังนั้นถ้าคุณกำลังใช้ autopublish อยู่ คุณก็ไม่จำเป็นต้องคิดถึงเรื่องการเผยแพร่ข้อมูล เพราะข้อมูลเหมือนกันทุกที่ และทุกอย่างก็ง่ายไปหมด แต่ก็แน่นอนว่าจะมีปัญหาเรื่องที่ข้อมูลของแอพคุณไปเก็บที่เครื่องของผู้ใช้ทุกเครื่อง

ด้วยเหตุนี้ การใช้วิธีเผยแพร่อัตโนมัติ จึงเหมาะที่จะใช้ในตอนเริ่มต้น และยังไม่ได้คิดเรื่องการเผยแพร่ข้อมูลเลย

การเผยแพร่ทั้งคอลเลคชั่น

เมื่อคุณยกเลิก autopublish คุณก็จะรู้ว่าข้อมูลคุณหายไปจากไคลเอนต์ทันที วิธีง่ายๆที่จะเรียกมันกลับมาคือ เลียนแบบวิธีที่การเผยแพร่อัตโนมัติทำไว้ โดยเผยแพร่หมดทั้งคอลเลคชั่น ดังเช่น

Meteor.publish('allPosts', function(){
  return Posts.find();
});
Publishing a full collection
Publishing a full collection

จะเห็นว่าเรายังคงเผยแพร่ข้อมูลทั้งคอลเลคชั่น แต่อย่างน้อยเราก็ควบคุมว่าคอลเลคชั่นไหนที่เราจะเผยแพร่ หรืออันไหนที่เราไม่ทำ ในตัวอย่างนี้เราทำการเผยแพร่แค่คอลเลคชั่น Posts แต่ยกเว้น Comments

การเผยแพร่บางส่วนของคอลเลคชั่น

ขั้นต่อไปที่เราจะควบคุมก็คือ การเผยแพร่ บางส่วน ของคอลเลคชั่น ในตัวอย่าง เราต้องการแค่โพสต์ที่เป็นของผู้แต่งบางคนเท่านั้น

Meteor.publish('somePosts', function(){
  return Posts.find({'author':'Tom'});
});
Publishing a partial collection
Publishing a partial collection

เบื้องหลังการทำงาน

ถ้าคุณได้อ่าน เอกสารเรื่องการเผยแพร่ข้อมูลของ Meteor คุณน่าจะเห็นการอธิบายวิธีใช้ add() และ ready() เพื่อตั้งค่าแอททริบิวท์ของรายการข้อมูลที่ฝั่งไคลเอนต์มาพอสมควร และรู้สึกขัดแย้งว่า แอพ Meteor ที่คุณเคยเห็นมานั้นไม่เคยใช้คำสั่งพวกนี้เลย

เหตุผลก็คือ Meteor มีวิธีอื่นที่สะดวกสบายกว่านั้นมาก ด้วยคำสั่ง _publishCursor() ที่คุณก็ไม่เคยเห็นใครใช้มันเหมือนกัน แต่ถ้าคุณคืนค่า เคอร์เซอร์ (ด้วยคำสั่ง Posts.find({'author':'Tom'})) ในฟังก์ชัน publish เมื่อไหร่ Meteor ก็จะเรียกใช้มันทันที

เมื่อ Meteor เห็นการเผยแพร่ข้อมูล somePosts ที่ส่งคืนค่าเคอร์เซอร์ มันจะเรียกใช้ _publishCursor() เพื่อ (คุณลองเดาดู) เผยแพร่เคอร์เซอร์นั้นโดยอัตโนมัติ

โดยฟังก์ชัน _publishCursor() ทำหน้าที่ต่อไปนี้

  • ตรวจสอบชื่อของคอลเลคชั่นบนเซิร์ฟเวอร์
  • ดึงเอกสารที่ตรงตามเงื่อนไขจากเคอร์เซอร์ และส่งไปที่คอลเลคชั่นของไคลเอนต์ที่มี ชื่อเหมือนกัน (โดยเรียกใช้ .added() ให้ทำงานนี้)
  • เมื่อใดที่เอกสารถูกเพิ่ม ลบออก หรือมีการแก้ไข มันจะส่งค่าการเปลี่ยนแปลงนั้นไปที่คอลเลคชั่นของไคลเอนต์ (โดยใช้ .observe() กับเคอร์เซอร์ และ .added(), .changed(), removed() เพื่อทำงานนี้)

ดังนั้นจากตัวอย่างข้างบน เราก็แน่ใจได้ว่าผู้ใช้จะได้รับแค่โพสต์ที่สนใจ (เขียนโดย Tom) มาเก็บอยู่ในแคชที่ไคลเอนต์พร้อมสำหรับการเรียกใช้งานทันที

การเผยแพร่บางฟิลด์ของข้อมูล

เราได้เห็นวิธีการเผยแพร่แค่บางส่วนของโพสต์แล้ว แต่เราก็ยังจะเฉือนข้อมูลให้บางลงอีก! ลองมาดูกันว่าจะทำอย่างไรเมื่อต้องการเผยแพร่แค่ บางฟิลด์ เท่านั้น

ก็เหมือนกับก่อนหน้านี้ เราจะใช้ find() เพื่อส่งคืนค่าเคอร์เซอร์ แต่ตอนนี้เราจะตัดบางฟิลด์ออก

Meteor.publish('allPosts', function(){
  return Posts.find({}, {fields: {
    date: false
  }});
});
Publishing partial properties
Publishing partial properties

และก็แน่นอนว่าเราสามารถผสมเทคนิคทั้งสองเข้าด้วยการ เช่น ถ้าเราต้องการส่งโพสต์ของทอม โดยตัดวันที่ออก เราก็จะเขียนโค้ดแบบนี้

Meteor.publish('allPosts', function(){
  return Posts.find({'author':'Tom'}, {fields: {
    date: false
  }});
});

สรุป

เราได้เห็นวิธีการเผยแพร่ข้อมูล ตั้งแต่ทุกฟิลด์ของทุกเอกสารของทุกคอลเลคชั่น (ด้วย autopublish) จนถึงการเผยแพร่แค่ บางฟิลด์ ของ บางเอกสาร ของ บางคอลเลคชั่น

วิธีการเหล่านี้ครอบคลุมพื้นฐานที่คุณสามารถใช้กับการเผยแพร่ข้อมูลใน Meteor และเทคนิคง่ายๆ พวกนี้ก็ช่วยในการใช้งานได้หลากหลายกรณี

ในบางครั้งคุณอาจจำเป็นต้องทำมากกว่านี้ ด้วยการรวม เชื่อม และผสานการเผยแพร่ข้อมูลต่างๆเข้าด้วยกัน ซึ่งเราจะพูดถึงเรื่องพวกนี้ในบทต่อๆไป!

การจัดเส้นทาง

5

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

โดยเราจะทำให้หน้าพวกนี้เรียกใช้งานได้จาก ลิงก์ถาวร (permalink) ในรูปแบบของ http://myapp.com/posts/xyz ซึ่งแต่ละข่าวที่โพสต์จะมีค่า xyz แตกต่างกันไปตามค่าตัวแปร _id ของ MongoDB

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

เพิ่มแพ็คเกจ Iron Router เข้าไปในแอพ

Iron Router เป็นแพ็คเกจที่ใช้เพื่อจัดเส้นทางการทำงาน ถูกสร้างขึ้นมาเพื่อใช้กับแอพของ Meteor โดยเฉพาะ

มันไม่เพียงแต่จะช่วยในเรื่องของการจัดเส้นทาง (กำหนดชื่อเส้นทางต่างๆ) แต่ยังช่วยในเรื่องฟิลเตอร์ (กำหนดการทำงานให้กับแต่ละเส้นทาง) และจัดการเรื่องการบอกรับข้อมูลด้วย (ควบคุมว่าเส้นทางไหนเข้าถึงข้อมูลใด) (หมายเหตุ: บางส่วนของ Iron Router พัฒนาโดย Tom Coleman ผู้แต่งร่วมของหนังสือ Discover Meteor เล่มนี้)

เราเริ่มด้วยการติดตั้งแพ็คเกจจาก Atmosphere

meteor add iron:router
Terminal

คำสั่งนี้จะทำการดาวน์โหลดและติดตั้งแพ็คเกจ Iron Router ลงในแอพของเราให้พร้อมใช้งาน ซึ่งบางครั้งคุณอาจต้องรีสตาร์ทแอพใหม่อีกครั้ง (โดยการกด ctrl+c เพื่อปิดโปรเซส และเรียก meteor เพื่อเริ่มต้นใหม่อีกครั้ง) ก่อนที่จะสามารถใช้งานแพ็คเกจได้

คำศัพท์เกี่ยวกับตัวจัดการเส้นทาง (Router)

ในบทนี้เราจะต้องเข้าไปเกี่ยวข้องกับเรื่องต่างๆของตัวจัดการเส้นทาง ซึ่งถ้าคุณเคยมีประสบการณ์กับเฟรมเวิร์กอย่าง Rail มาบ้าง คุณก็คงจะรู้สึกคุ้นเคยกับเรื่องพวกนี้ แต่ถ้าไม่เคยมาก่อน คำอธิบายศัพท์ต่อไปนี้จะช่วยให้คุณเข้าใจได้อย่างรวดเร็ว

  • ข้อมูลเส้นทาง (routes): คือข้อมูลพื้นฐานที่ใช้เพื่อจัดการเส้นทาง ซึ่งก็คือ ชุดคำสั่งที่บอกแอพว่าต้องไปที่ไหน และต้องทำอะไรเมื่อรับค่า URL เข้ามา

  • พาธ (paths): คือ URL ภายในแอพของคุณ ที่อาจจะเป็นแบบตายตัว (/terms_of_service) หรือแบบปรับเปลี่ยนได้ (/posts/xyz) และอาจเป็นแบบที่มีตัวแปรติดมาด้วยก็ได้ (/search?keyword=meteor)

  • เซกเมนต์ (segments): คือส่วนต่างๆของพาธ คั่นด้วยเครื่องหมาย /

  • ฮุค (hooks): คือการทำงานที่คุณต้องการให้มีในระหว่างก่อน, หลัง, หรือตอนที่กำลังเปลี่ยนเส้นทาง ตัวอย่างที่เห็นได้ทั่วไปคือ การตรวจสอบสิทธิของผู้ใช้งานว่าเพียงพอก่อนที่จะแสดงหน้าเว็บหรือไม่

  • ฟิลเตอร์ (filters): จริงๆ ก็คือ ฮุคที่คุณกำหนดให้ทำงานกับทุกๆเส้นทาง

  • เทมเพลทเส้นทาง (route templates): แต่ละเส้นทางต้องมีเทมเพลท ซึ่งถ้าคุณไม่ได้กำหนดไว้ ตััวจัดการเส้นทางจะมองหาเทมเพลทที่มีชื่อเดียวกับชื่อเส้นทางนั้นโดยอัตโนมัติ

  • เลย์เอาท์ (layout): คุณอาจมองเลย์เอาท์ให้เป็น “เฟรม” ของหน้าแอพก็ได้ ซึ่งมันจะประกอบไปด้วยโค้ด HTML ที่หุ้มเทมเพลทไว้อีกชั้น และจะไม่เปลี่ยนแปลงแม้ว่าเทมเพลทจะเปลี่ยนไป

  • คอนโทรลเลอร์ (controller): ในบางครั้งคุณอาจมีเทมเพลทอยู่หลายตัวที่ใช้งานตัวแปรพารามิเตอร์เหมือนๆ กัน ดังนั้นแทนที่จะต้องเขียนโค้ดซ้ำๆ กัน คุณอาจจะระบุให้เส้นทางพวกนั้นใช้โค้ดที่สืบทอดมาจาก คอนโทรลเลอร์จัดการเส้นทาง ตัวเดียวกันก็ได้

ถ้าคุณต้องการรายละเอียดที่มากขึ้นเกี่ยวกับ Iron Router คุณควรไปดูที่ หน้าเอกสารบน GitHub

จัดเส้นทาง: ผูก URL เข้ากับเทมเพลท

ที่ผ่านมาเราได้สร้างเลย์เอาท์ด้วยการเขียนโค้ดลงไปในเทมเพลทด้วยตัวแทนที่ เช่น {{>postsList}} ซึ่งแม้ว่าเนื้อหาในหน้าแอพจะเปลี่ยนแปลงตามข้อมูลได้ แต่โครงสร้างหลักของหน้าก็ยังเหมือนเดิม คือมีหัวเรื่องอยู่บน ตามด้วยรายการชื่อเรื่องที่โพสต์อยู่ข้างล่าง

Iron Router ช่วยให้เราหลุดออกจากกรอบนี้ได้ โดยทำหน้าที่สร้างเนื้อหาภายในแท็ก <body> ให้เรา นั่นคือเราไม่ต้องสร้างแท็กเองทั้งหน้าแบบที่เราเคยทำกับ HTML ทั่วไป เราแค่บอกตัวจัดการเส้นทางว่าเราจะใช้เทมเพลทเลย์เอาท์ตัวไหนก็พอ ซึ่งเทมเพลทเลย์เอาท์นี้จะต่างจากเทมเพลทอื่นตรงที่มีตัวช่วย {{>yield}} อยู่ข้างใน

โดยตัวช่วย {{>yield}} นี้จะสร้างพื้นที่พิเศษขึ้นในหน้าเว็บ แล้วนำเนื้อหาที่ได้จากการทำงานของเทมเพลทที่เราผูกเข้ากับเส้นทางการทำงานปัจจุบันมาใส่ให้เราโดยอัตโนมัติ (เพื่อให้เข้าใจตรงกัน จากนี้ไปเราจะเรียกเทมเพลทพิเศษตัวนี้ว่า “เทมเพลทเส้นทาง”)

Layouts and templates.
Layouts and templates.

เราจะเริ่มด้วยการสร้างเลย์เอาท์ที่มีตัวช่วย {{>yield}} อยู่ข้างใน โดยแรกสุด ให้เราย้ายแท็ก <body> ทั้งชุดออกจากหน้า main.html แล้วนำเข้าไปไว้ในเทมเพลทใหม่ชื่อ layout.html ที่เราสร้างไว้ในโฟลเดอร์ client/templates/application

ซึ่ง Iron Router จะจัดการนำแท็กในเลย์เอาท์มาใส่ใน main.html ที่ถูกลดรูปลงเป็นตามที่เห็นนี้ ให้เราโดยอัตโนมัติ :

<head>
  <title>Microscope</title>
</head>
client/main.html

โดยไฟล์ layout.html ก็จะประกอบด้วยเลย์เอาท์ของหน้าแอพเราดังนี้ :

<template name="layout">
  <div class="container">
    <header class="navbar navbar-default" role="navigation">
      <div class="navbar-header">
        <a class="navbar-brand" href="/">Microscope</a>
      </div>
    </header>
    <div id="main">
      {{> yield}}
    </div>
  </div>
</template>
client/templates/application/layout.html

ให้สังเกตุว่าเราได้เปลี่ยนจากชื่อเทมเพลท postsList มาเรียกใช้ตัวช่วย yield แล้ว

หลังจากที่เราทำการเปลี่ยนแปลงแล้ว เบราว์เซอร์ก็จะแสดงหน้าข้อความช่วยเหลือของ Iron Router ที่เป็นเช่นนี้เพราะเรายังไม่ได้บอกตัวจัดการเส้นทางว่าให้ทำอะไรเมื่อเปิดใช้แอพที่ URL / ดังนั้นมันก็เลยโหลดเทมเพลทว่างๆมาใช้

เพื่อแก้ไขให้หน้าจอกลับมาใช้งานได้เหมือนเดิม เราก็จะผูกพาธที่ / เข้ากับเทมเพลท postsList โดยเราจะสร้างไฟล์ router.js ในโฟลเดอร์ /lib ภายในโปรเจกต์ของเรา ดังนี้ :

Router.configure({
  layoutTemplate: 'layout'
});

Router.route('/', {name: 'postsList'});
lib/router.js

ในไฟล์นี้ เราได้ทำเรื่องสำคัญไปสองอย่าง อย่างแรกคือ เราบอกตัวจัดการเส้นทางให้ใช้เทมเพลทชื่อ layout ที่เราเพิ่งสร้างเป็นเทมเพลทพื้นฐานของทุกๆ เส้นทาง

อย่างที่สอง เราได้สร้างเส้นทางใหม่ชื่อ postsList และผูกเข้ากับพาธเริ่มต้นที่ /

โฟลเดอร์ /lib

ไม่ว่าคุณจะวางไฟล์อะไรไว้ในโฟลเดอร์ lib มันจะถูกโหลดมาทำงานเป็นอันดับแรกก่อนไฟล์อื่นๆ ในแอพของคุณเสมอ (ยกเว้นเฉพาะแพ็คเกจบางประเภทเท่านั้น) ที่ตรงนี้จึงเหมาะกับการวางโค้ดตัวช่วยต่างๆ ที่จำเป็นต้องเรียกใช้งานได้ตลอดเวลา

มีคำเตือนเล็กๆ ว่า เนื่องจาก /lib ไม่ได้อยู่ใน /client หรือ /server ก็หมายความว่าอะไรที่อยู่ในนั้นจะเรียกใช้ได้จากทั้งสองฝั่ง

ชื่อเส้นทาง

เราตั้งชื่อเส้นทางของเราว่า postsList และเราก็ยังมี เทมเพลท ชื่อ postsList อยู่ด้วย ซึ่งคุณอาจจะงงว่าเกิดอะไรขึ้น ดังนั้นเพื่อคลายความสงสัยเราจะมาหาคำตอบกัน

โดยปกติ Iron Router จะมองหาเทมเพลทที่มีชื่อเหมือนกับชื่อเส้นทาง อันที่จริงมันหาจากชื่อพาธที่เราให้ด้วยซ้ำ ซึ่งในกรณีนี้มันจะหาไม่พบ (เพราะว่าพาธเราคือ /) แต่ถ้าเราเรียกใช้พาธ http://localhost:3000/postsList แทน Iron Router ก็จะหาเทมเพลทให้เราได้

คุณอาจจะสงสัยว่าแล้วทำไมเรายังต้องตั้งชื่อเส้นทางไว้ด้วย การตั้งชื่อเส้นทางช่วยให้เราใช้คุณสมบัติบางอย่างของ Iron Router ที่ทำให้การสร้างลิงก์ต่างๆในแอพง่ายขึ้น โดยตัวช่วยของ Spacebars ที่มีประโยชน์มากที่สุดคือ {{pathFor}} ก็ใช้ชื่อเส้นทางมาสร้างลิงก์เส้นทางต่างๆ ให้เราได้ง่ายๆ

ถ้าเราต้องการให้มีลิงก์กลับไปที่หน้าหลักของแอพเพื่อแสดงหัวข้อข่าวที่โพสต์ เราก็สามารถทำได้โดยใช้ตัวช่วย Spacebars แทนการใช้พาธตายตัวอย่าง / โดยผลลัพธ์ที่ได้ยังคงเดิม แต่ที่เราได้มาคือความยืดหยุ่น เพราะว่าตัวช่วยจะคืนค่าพาธที่ถูกต้องให้เราเสมอ แม้ว่าภายหลังเราจะเปลี่ยนพาธของเส้นทางนั้นๆที่ตัวจัดการเส้นทางไปแล้ว

<header class="navbar navbar-default" role="navigation">
  <div class="navbar-header">
    <a class="navbar-brand" href="{{pathFor 'postsList'}}">Microscope</a>
  </div>
</header>

//...
client/templates/application/layout.html

กว่าข้อมูลจะมา

ถ้าคุณดีพลอยแอพเวอร์ชั่นปัจจุบัน หรือเปิดดูจากปุ่ม Launch Instance ด้านบน คุณจะสังเกตุเห็นว่ารายการข่าวที่โพสต์จะหายไปขณะหนึ่งแล้วถึงจะปรากฏขึ้น นั่นก็เพราะตอนที่หน้าเพจแรกถูกโหลดมาจะไม่มีข้อมูลใดๆแสดงในหน้าจอจนกว่าการดึงข้อมูล posts จากเซิร์ฟเวอร์จะเสร็จสิ้น

ซึ่งในระหว่างที่รอตรงนั้น ถ้าเราบอกผู้ใช้แอพว่า กำลังเกิดอะไรขึ้นและให้รอซักครู่ จะทำให้ผู้ใช้เข้าใจและมีความรู้สึกที่ดีกับแอพเรามากขึ้น

โชคดีที่ Iron Router ช่วยให้เราทำแบบนั้นได้ง่ายๆ เราแค่บอกให้มันรอรับข้อมูลให้เรียบร้อยเสียก่อน แล้วถึงค่อยทำงานต่อ

โดยเราก็แค่ย้ายโค้ดการบอกรับข้อมูล posts จากใน main.js มาไว้ที่ตัวจัดการเส้นทางแทน

Router.configure({
  layoutTemplate: 'layout',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});
lib/router.js

ที่เรากำหนดตรงนี้ก็คือ ในทุกๆเส้นทางของแอพ (ตอนนี้เรามีแค่เส้นทางเดียว แต่ในไม่ช้าเราจะมีเพิ่ม! ) เราต้องการบอกรับข้อมูล posts มาใช้งาน

สิ่งที่แตกต่างกันระหว่างโค้ดตรงนี้กับที่เราเขียนก่อนหน้า (ที่การบอกรับข้อมูลเกิดขึ้นใน main.js ซึ่งตอนนี้ไม่มีแล้ว และสามารถลบไฟล์ทิ้งได้) ก็คือ ตอนนี้ Iron Router รู้ว่าเส้นทางจะ “พร้อมใช้” เมื่อได้รับข้อมูลที่บอกรับมาเรียบร้อยแล้ว

กำลังโหลดอยู่นะ

การรู้ว่าเมื่อไรเส้นทาง postsList จะพร้อมไม่ได้ทำให้อะไรดีขึ้นถ้าเรายังคงแสดงหน้าเทมเพลทว่างๆอยู่ ก็ต้องขอบคุณ Iron Router ที่มาพร้อมกับวิธีที่จะชะลอการแสดงเทมเพลทจนกว่าเส้นทางจะพร้อม และตอนที่รอนั้นยังสามารถแสดงเทมเพลท loading ได้ด้วย ดังนี้ :

Router.configure({
layoutTemplate: 'layout',
loadingTemplate: 'loading',
waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});
lib/router.js

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

ส่วนสุดท้ายที่เราต้องทำคือเทมเพลทขณะโหลดข้อมูล เราจะใช้แพ็คเกจ spin มาสร้างภาพเคลื่อนไหวแสดงการโหลด ด้วยการใช้คำสั่ง meteor add sacha:spin และสร้างเทมเพลท loading ลงในโฟลเดอร์ client/templates/includes ดังนี้ :

<template name="loading">
  {{>spinner}}
</template>
client/templates/includes/loading.html

สังเกตุว่า {{>spinner}} เป็นโค้ดตัวช่วยที่อยู่ในแพ็คเกจ spin ซึ่งแม้ว่ามันจะอยู่นอกแอพ เราก็สามารถเรียกมาใช้งานได้เหมือนเทมเพลทตัวอื่นๆ

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

คอมมิท 5-2

Wait on the post subscription.

แวบมาดู Reactivity กันหน่อย

Reactivity คือแกนหลักของ Meteor ซึ่งแม้เราจะยังไม่ได้ไปถึงเรื่องนี้ แต่เทมเพลทแสดงการโหลดก็ช่วยให้เรามองเห็นแนวคิดของเรื่องนี้ได้บ้าง

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

ตอนนี้ขอตอบแค่ว่า ต้องใช้วิธีการ Reactivity เข้ามาจัดการ ซึ่งเราจะได้เรียนรู้เรื่องนี้กันมากขึ้น เร็วๆ นี้ !

จัดเส้นทางไปที่หน้าข่าว

ตอนนี้เมื่อเรารู้วิธีจัดเส้นทางไปยังเทมเพลท postsList กันแล้ว เราจะมาลองจัดเส้นทางไปที่หน้าแสดงข่าวกันดูบ้าง

จุดที่เราต้องสนใจ คือ เราคงไม่สามารถกำหนดเส้นทางให้กับทุกๆข่าวที่มีอยู่ได้ เพราะว่า มันอาจมีได้หลายร้อยเรื่อง ที่เราจะทำ คือ สร้างเส้นทางแบบยืดหยุ่น และใช้เส้นทางนั้นแสดงหน้าข่าวที่เราต้องการ

ก่อนอื่นเราก็สร้างเทมเพลทขึ้นใหม่ ให้แสดงข้อมูลเหมือนกับที่เราใช้ในหน้ารายการข่าว

<template name="postPage">
  <div class="post-page page">
    {{> postItem}}
  </div>
</template>
client/templates/posts/post_page.html

โดยเราจะเพิ่มข้อมูลอื่นๆ เข้าไปที่เทมเพลทนี้ตอนหลัง (เช่น คอมเมนต์) แต่ตอนนี้ใช้มันเป็นแค่เปลือกหุ้มให้กับ {{> postItem}} ไปพลางๆ ก่อน

ต่อมาเราก็สร้างเส้นทางใหม่ โดยครั้งนี้ให้ผูกพาธ /posts/<ID> เข้ากับเทมเพลท postPage

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});
Router.route('/posts/:_id', {
  name: 'postPage'
});

lib/router.js

การใช้ _id จะบอกให้ตัวจัดการเส้นทางทำสองอย่าง อย่างแรกคือ ให้หาเฉพาะเส้นทางที่อยู่ในรูป /posts/xyz โดยที่ “xyz” เป็นอะไรก็ได้ อย่างที่สองคือ ให้นำค่าที่ได้จาก “xyz” ไปใส่ที่พารามิเตอร์ _id ในอาร์เรย์ params ของตัวจัดการเส้นทาง

สังเกตุด้วยว่าเราใช้ _id เพื่อความสะดวกเท่านั้น ตัวจัดการเส้นทางไม่ได้รู้ว่าเราส่งค่า _id จริงๆ หรือตัวอักษรอะไรมาให้

ถึงตรงนี้เราก็มีเส้นทางไปยังเทมเพลทแล้ว แต่ยังขาดอะไรบางอย่างอยู่ ตัวจัดการเส้นทางรู้ว่าเราจะแสดงข่าวด้วย _id อะไรแล้ว แต่เทมเพลทยังไม่รู้ตรงนี้เลย แล้วเราควรจะทำยังไงดี

ต้องขอบคุณตัวจัดการเส้นทางอีกครั้งที่ได้เตรียมทางออกไว้ให้เรา มันยอมให้เรากำหนด ชุดข้อมูล (data context) ให้กับเทมเพลทได้ คุณอาจคิดเทียบเคียงได้ว่า ถ้าเรามีเค้กที่ทำมาจากเทมเพลทกับเลย์เอาท์ ตัวไส้เค้กข้างในก็คือ ชุดข้อมูลนั่นเอง พูดง่ายๆ มันคือ อะไรก็ตามที่คุณจะเอาไปใส่ไว้ในเทมเพลท

The data context.
The data context.

ในกรณีของเรา ชุดข้อมูลที่จะส่งให้เทมเพลท จะมาจากข้อมูลข่าวตาม _id ที่ได้จาก URL ดังนี้ :

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});
Router.route('/posts/:_id', {
  name: 'postPage',
  data: function() { return Posts.findOne(this.params._id); }
});
lib/router.js

โดยทุกครั้งที่ผู้ใช้เข้ามาตามเส้นทางนี้ เราก็แค่หาข้อมูลข่าวที่เหมาะสมและส่งต่อให้เทมเพลท ทั้งนี้ให้จำไว้ว่า findOne จะคืนข้อมูลข่าวที่ตรงกับการค้นหามาตัวเดียว และการใช้แค่ id เป็นค่าพารามิเตอร์ก็เหมือนกับการส่งค่า {_id: id} เข้าไปนั่นเอง

ภายในฟังก์ชัน data ของแต่ละเส้นทาง ตัวแปร this จะหมายถึงเส้นทางปัจจุบัน เราจึงสามารถใช้ this.params เพื่อเข้าถึงส่วนที่เป็นพารามิเตอร์ของเส้นทางได้ (ตัวที่เราใส่เครื่องหมาย : ไว้ข้างหน้า)

รู้จักชุดข้อมูลกันหน่อย

การกำหนดชุดข้อมูลให้กับเทมเพลท ทำให้เราควบคุมค่าของ this ในตัวช่วยเทมเพลทได้

ปกติแล้วชุดข้อมูลที่ใช้กับเทมเพลท จะถูกกำหนดโดยอัตโนมัติจากตัวดำเนินการ {{#each}} ซึ่งในทุกรอบการทำงานจะสร้างชุดข้อมูลขึ้นใหม่ด้วยค่าของตัวแปรตามรอบนั้นๆ :

{{#each widgets}}
  {{> widgetItem}}
{{/each}}

แต่เราก็สามารถระบุได้เองว่าเราจะใช้ชุดข้อมูลจากตัวแปรอะไร ด้วยตัวดำเนินการ {{#with}} ซึ่งทำงานเหมือนกับคำพูดที่ว่า “นำค่าจากตัวแปรนี้ ไปใช้กับเทมเพลทที่ระบุ” ดังตัวอย่างนี้ :

{{#with myWidget}}
  {{> widgetPage}}
{{/with}}

และยังมีอีกวิธีที่ให้ผลเหมือนกันคือ ส่งค่าตัวแปรที่จะใช้เป็นชุดข้อมูลเหมือนเป็นพารามิเตอร์ของเทมเพลทที่เรียกใช้ดังนี้ :

{{> widgetPage myWidget}}

ถ้าต้องการเรียนรู้วิธีใช้งานชุดข้อมูลที่ลึกกว่านี้ เราแนะนำให้ไป อ่านต่อที่หน้าบล็อกของเราได้

การใช้ตัวช่วยสร้างพาธ

ในช่วงท้ายนี้เราจะสร้างปุ่ม “Discuss” เพื่อเชื่อมไปยังหน้าข่าวแต่ละข่าว ซึ่งจริงๆ เราสามารถทำได้ด้วยแท็ก <a href="/posts/{{_id}}"> อยู่แล้ว แต่ถ้าเราใช้ตัวช่วยสร้างพาธก็จะได้อะไรที่แน่นอนกว่า

ที่เราต้องทำคือใช้ตัวช่วย {{pathFor 'postPage'}} เพื่อสร้างพาธไปที่หน้าข่าวตามเส้นทาง `postPage’ ที่เราสร้างไว้

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn btn-default">Discuss</a>
  </div>
</template>
client/templates/posts/post_item.html

คอมมิท 5-3

Routing to a single post page.

แต่ช้าก่อน ไม่สงสัยกันบ้างหรือว่า ตัวจัดการเส้นทางรู้ได้อย่างไรว่าจะหาค่า xyz จาก /posts/xyz มาได้ยังไง เพราะว่าเราก็ไม่ได้ส่งค่า _id ไปให้ด้วยซ้ำ

สิ่งที่เกิดขึ้นก็คือ Iron Router ฉลาดพอที่จะรู้ได้ด้วยตัวเอง เราแค่บอกมันให้ใช้เส้นทาง postPage และมันก็รู้ว่าเส้นทางนี้ต้องใช้ค่า _id จากที่เรากำหนดไว้ใน path

ดังนั้นมันก็มองหา _id จากข้อมูลที่มีอยู่ ซึ่งก็คือ ชุดข้อมูลที่ตัวช่วย {{pathFor 'postPage'}} ใช้อยู่ หรืออีกนัยนึงก็คือ this นั่นเอง และ this ของเราจริงๆ แล้วก็คือ หัวข้อข่าวซึ่งมีค่า _id ติดมาด้วยอย่างน่าแปลกใจ !

ยังมีอีกวิธีที่คุณสามารถบอกตัวจัดการเส้นทางไปเลยว่าคุณต้องการให้มันหา _id ที่ไหน ด้วยการส่งค่าพารามิเตอร์ตัวที่สองให้กับตัวช่วย เช่น {{pathFor 'postPage' someOtherPost}} ซึ่งวิธีนี้มักใช้เมื่อต้องการสร้างลิงก์ไปที่ข่าวก่อนหน้าหรือที่มาทีหลังตามลำดับที่โพสต์ไว้

และตอนนี้ก็ถึงเวลาทดสอบดูว่ามันทำงานถูกต้องมั้ย ด้วยการเปิดเบราว์เซอร์ไปที่หน้าหัวข้อข่าว แล้วคลิ๊กที่ปุ่ม “Discuss” ซักปุ่มนึง คุณก็ควรจะเห็นหน้าเว็บเป็นแบบนี้ :

A single post page.
A single post page.

HTML5 pushState

สิ่งหนึ่งที่ควรเข้าใจคือ การเปลี่ยนแปลงค่า URL เกิดขึ้นได้จากการใช้ HTML5 pushState

โดยตัวจัดการเส้นทางจะดักจับการคลิ๊กลิงก์ของทุก URL ที่อยู่ภายในแอพ และป้องกันไม่ให้เบราว์เซอร์เปิดหน้าอื่นนอกเหนือจากในแอพ ซึ่งจริงๆแล้วมันก็แค่เปลี่ยนแปลงค่าสถานะของแอพเท่านั้น

ซึ่งถ้าทุกอย่างทำงานถูกต้องหน้าเพจก็จะเปลี่ยนไปทันที อันที่จริงบางครั้งมันเปลี่ยนเร็วเกินไปด้วยซ้ำ จนบางทีอาจจำเป็นต้องใช้ตัวแสดงการเปลี่ยนหน้า (page transition) ซึ่งเกินขอบเขตของบทนี้ แต่ก็เป็นเรื่องที่น่าสนใจทีเดียว

หาข่าวที่โพสต์ไม่เจอ .. ทำไงดี

ต้องไม่ลืมว่าการกำหนดเส้นทางเกิดได้จากทั้งสองทิศทาง ตัวจัดการเส้นทางอาจเปลี่ยน URL ให้เราเมื่อเราเปิดเพจ หรือมันอาจแสดงหน้าเพจเมื่อเราเปลี่ยน URL ก็ได้ ดังนั้นเราจึงต้องหาวิธีป้องกันถ้ามีใครป้อน URL ผิดๆเข้าไป

ขอบคุณอีกครั้งที่ Iron Router ดูแลเราในเรื่องนี้ด้วยตัวเลือก notFoundTemplate

เริ่มด้วยการสร้างเทมเพลทขึ้นใหม่ เพื่อใช้แสดงข้อความผิดพลาดจากโค้ด 404 แบบง่ายๆ

<template name="notFound">
  <div class="not-found page jumbotron">
    <h2>404</h2>
    <p>Sorry, we couldn't find a page at this address.</p>
  </div>
</template>
client/templates/application/not_found.html

จากนั้นก็แค่บอก Iron Router ให้ใช้เทมเพลทใหม่นี้ :

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

lib/router.js

ลองมาทดสอบหน้าแสดงข้อความผิดพลาดกันดู ด้วยการลองเข้าไปที่ URL แปลกๆ เช่น http://localhost:3000/nothing-here

แต่ช้าก่อน ถ้าบางคนป้อน URL แบบนี้ล่ะ http://localhost:3000/posts/xyz โดยใช้ค่า xyz ที่ไม่ใช่ _id จริง ซึ่งเป็นรูปแบบเส้นทางที่ถูกต้อง เพียงแต่ว่าไม่ได้ชี้ไปที่ข้อมูลจริงเท่านั้น

Iron Router ไม่ทำให้เราผิดหวัง มันฉลาดพอที่จะรู้ตรงนี้ ถ้าเราแค่เพิ่มฮุค dataNotFound ไว้ที่ตอนท้ายของ router.js :

//...

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
lib/router.js

จะเป็นการบอก Iron Router ให้แสดงหน้า “not found” กับเส้นทางที่ผิด และกับเส้นทาง postPage ที่ฟังก์ชัน data คืนค่าผิดๆ (เช่น null, false, undefined หรือค่าว่าง) กลับมา

คอมมิท 5-4

Added not found template.

ทำไมต้องเป็น “Iron”

คุณอาจสงสัยว่าการใช้ชื่อ “Iron Router” มีที่มาอย่างไร ตามที่ Chris Mather ผู้สร้าง Iron Router บอกไว้ ชื่อนี้ได้มาจากความจริงที่ว่าส่วนประกอบหลักๆของ meteor ก็คือ iron นั่นเอง

เซสชั่น

Sidebar 5.5

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

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

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

เซสชันของ Meteor

ในเวอร์ชันนี้แอพ Microscope ได้ข้อมูลสถานะปัจจุบันของผู้ใช้งานมาจาก URL ที่กำลังเปิดใช้งานอยู่ (รวมทั้งที่อยู่ในฐานข้อมูล)

แต่ยังมีอีกหลายกรณีที่คุณจำเป็นต้องเก็บสถานะบางอย่างที่เกี่ยวข้องกับผู้ใช้แอพคนปัจจุบันไว้ชั่วคราว (เช่น การซ่อนหรือแสดงข้อมูลบางอย่าง) การใช้เซสชันจึงเหมาะกับเรื่องนี้

เซสชัน เป็นที่เก็บข้อมูลชนิดรีแอคทีฟแบบทั่วถึง (global reactive data store) ความหมายคือ มันเป็นอ็อบเจกต์เดี่ยวๆแบบทั่วถึง มีอยู่ตัวเดียวแต่เรียกใช้งานได้จากทุกที่ ซึ่งตัวแปรแบบทั่วถึงนี้ มักถูกมองว่าไม่ควรเอามาใช้ แต่ในกรณีนี้เซสชันถูกนำมาใช้เป็นช่องทางสื่อสารกลาง เพื่อให้ส่วนต่างๆในแอปพลิเคชันติดต่อถึงกันได้

การเปลี่ยนค่า Session

เซสชั่นสามารถเรียกใช้ได้จากทุกๆที่ในไคลเอนต์ด้วยอ็อบเจกต์ Session โดยมีวิธีการตั้งค่าให้ session ดังนี้

 Session.set('pageTitle', 'A different title');
Browser console

คุณสามารถอ่านค่ากลับมาได้ด้วยคำสั่ง Session.get('mySessionProperty'); และเนื่องจากมันเป็นข้อมูลชนิดรีแอคทีฟ ก็หมายความว่า ถ้าคุณใส่มันไว้ในตัวช่วยเทมเพลท คุณก็จะเห็นผลลัพธ์ที่ได้จากตัวช่วยนี้มีการเปลี่ยนแปลงตามค่าของตัวแปรเซสชันที่เปลี่ยนไป

ลองทำตามนี้ โดยเพิ่มโค้ดข้างล่างลงไปในเทมเพลตเลย์เอาท์

<header class="navbar navbar-default" role="navigation">
    <div class="navbar-header">
        <a class="navbar-brand" href="{{pathFor 'postsList'}}">{{pageTitle}}</a>
    </div>
</header>
client/templates/application/layout.html
Template.layout.helpers({
  pageTitle: function() { return Session.get('pageTitle'); }
});
client/templates/application/layout.js

โค้ดในบทแทรก

เนื่องจากโค้ดที่อยู่ในบทแทรกทั้งหมด ไม่ได้เป็นส่วนหนึ่งของแอพในหนังสือ ดังนั้นคุณอาจจะสร้าง branch ใหม่ (ถ้าใช้ Git) หรือแก้ไขโค้ดที่คุณเปลี่ยนไปกลับคืน เมื่อจบบทแทรกนี้

การรีโหลดโค้ดอัตโนมัติของ Meteor (hot code reload หรือ HCR) จะคงค่าเซสชันไว้ ดังนั้นคุณก็ควรจะเห็นข้อความ “A different title” แสดงในส่วน nav bar แต่ถ้าไม่เป็นแบบนั้น ให้คุณพิมพ์คำสั่ง Session.set() ข้างบนอีกครั้ง

ยิ่งไปกว่านั้น ถ้าเราเปลี่ยนค่ามันอีกครั้ง (ในคอนโซลของเบราว์เซอร์) เราก็ควรจะเห็นอีกข้อความเช่นกัน

 Session.set('pageTitle', 'A brand new title');
Browser console

เซสชัน เรียกใช้งานได้อย่างทั่วถึงในทุกที่ ดังนั้นจึงเปลี่ยนแปลงมันจากที่ไหนก็ได้ ข้อดีนี้ช่วยให้เราทำอะไรได้มากมาย แต่ก็อาจจะกลายเป็นกับดักได้ ถ้าเราใช้มากเกินไป

ยังมีเรื่องสำคัญที่ควรรู้ก็คือ เซสชันอ็อบเจกต์ ไม่สามารถ แบ่งปันข้อมูลระหว่างผู้ใช้ หรือระหว่างแท็บของเบราว์เซอร์ได้ นั่นคือเหตุผลว่า ถ้าตอนนี้คุณเปิดแอพในอีกแท็บนึง คุณจะพบหน้าที่มีแต่หัวเรื่องว่างๆเท่านั้น

การเปลี่ยนแปลงที่เหมือนเดิม

ถ้าคุณแก้ไขค่าของตัวแปรเซสชันด้วยคำสั่ง Session.set() แต่คุณยังใช้ค่าเหมือนเดิม Meteor ก็ฉลาดพอที่จะมองข้ามห่วงโซ่รีแอกทีฟนี้ และไม่เรียกใช้ฟังก์ชันโดยไม่จำเป็น

รู้จักกับ Autorun

เราได้ดูตัวอย่างของแหล่งข้อมูลชนิดรีแอคทีฟกันไปแล้วตัวนึง โดยมองการทำงานของมันผ่านตัวช่วยเทมเพลต ซึ่งในขณะที่โค้ดบางส่วนของ Meteor (เช่น ตัวช่วยเทมเพลต) ทำงานแบบรีแอคทีฟ โค้ดส่วนใหญ่ของแอพ Meteor ก็ยังคงเป็นจาวาสคริปต์ธรรมดาที่ไม่ใช่แบบรีแอคทีฟอยู่

สมมุติว่าเรามีโค้ดบางส่วนในแอพเป็นแบบนี้

helloWorld = function() {
  alert(Session.get('message'));
}

ซึ่งเรากำลังเรียกใช้ตัวแปรเซสชันอยู่ โดยโค้ดส่วนที่เรียกใช้มันไม่ได้เป็นรีแอคทีฟ นั่นหมายความว่าเมื่อเราเปลี่ยนค่าตัวแปร เราจะไม่ได้เห็นข้อความ alert เกิดขึ้นทุกครั้ง

เรื่องนี้เป็นสิ่งที่ Autorun เข้ามาช่วยได้ ถ้าแปลตามชื่อแล้ว โค้ดที่อยู่ในบล็อก autorun จะทำงานโดยอัตโนมัติ และทำงานทุกครั้งที่แหล่งข้อมูลชนิดรีแอคทีฟที่ถูกใช้ข้างในนั้นมีค่าเปลี่ยนไป

ลองพิมพ์โค้ดนี้เข้าไปที่คอนโซลของเบราว์เซอร์ดู

 Tracker.autorun( function() { console.log('Value is: ' + Session.get('pageTitle')); } );
Value is: A brand new title
Browser console

คุณก็พอจะบอกได้ว่า บล็อกของโค้ดที่อยู่ใน autorun จะทำงานครั้งแรกด้วยการแสดงข้อความออกมาที่คอนโซล ตอนนี้ให้ลองเปลี่ยนหัวเรื่องดู

 Session.set('pageTitle', 'Yet another value');
Value is: Yet another value
Browser console

มหัศจรรย์! เมื่อค่าของเซสชันเปลี่ยนไป autorun ก็รู้ว่ามันต้องรันโค้ดข้างในซ้ำอีกครั้ง และส่งข้อความใหม่ออกไปที่คอนโซล

ดังนั้นจากโค้ดของตัวอย่างก่อนหน้านี้ ถ้าเราต้องการให้มีข้อความเตือนใหม่ทุกครั้งเมื่อตัวแปรเซสชันเปลี่ยนไป ทั้งหมดที่เราต้องทำคือ หุ้มโค้ดของเราด้วยบล็อก autorun

Tracker.autorun(function() {
    alert(Session.get('message'));
});

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

การรีโหลดโค้ดอัตโนมัติ

ในระหว่างที่เรากำลังสร้าง Microscope กันนั้น เราได้ใช้คุณสมบัติหนึ่งในหลายๆอย่างของ Meteor ที่ช่วยลดเวลาการทำงานได้ นั่นก็คือ การรีโหลดอัตโนมัติ (hot code reload หรือ HCR) โดยเมื่อไรก็ตามที่เราบันทึกซอร์สโค้ด Meteor จะตรวจพบการเปลี่ยนแปลงนั้น และทำการรีสตาร์ทเซิร์ฟเวอร์ Meteor ขึ้นใหม่ แล้วบอกให้ไคลเอนต์ทำการรีโหลดหน้านั้นอีกครั้ง

ซึ่งดูไปจะคล้ายการรีโหลดอัตโนมัติของหน้าเพจ แต่มีความแตกต่างกันในเรื่องสำคัญอยู่อย่างนึง

เพื่อหาคำตอบเรื่องนี้ ให้เริ่มด้วยการรีเซ็ทค่าของตัวแปรเซสชันที่เราใช้มาก่อนหน้านี้

 Session.set('pageTitle', 'A brand new title');
 Session.get('pageTitle');
'A brand new title'
Browser console

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

 Session.get('pageTitle');
'A brand new title'
Browser console

ดังนั้นถ้าเราใช้ตัวแปรเซสชันเก็บค่าบางอย่างในขณะที่ผู้ใช้กำลังทำงานอยู่ ตัว HCR ก็จะทำงานอยู่เบื้องหลังแบบไม่มีตัวตน โดยเก็บรักษาค่าของตัวแปรเซสชันทั้งหมดนั้นไว้ให้ การทำงานแบบนี้ช่วยให้เราดีพลอยแอพ Meteor เวอร์ชั่นใหม่ๆ ของเราได้ด้วยความมั่นใจว่า ผู้ใช้งานจะได้รับผลกระทบน้อยที่สุด

จุดที่น่าสนใจคือ ถ้าเราสามารถเก็บค่าสถานะของเราทั้งหมดไว้ทั้งที่ URL และในเซสชันได้ เราก็จะสามารถเปลี่ยนแปลง ซอร์สโค้ดที่กำลังรันอยู่ ในแต่ละแอพของไคลเอนต์ได้ โดยส่งผลกระทบน้อยที่สุด

ตอนนี้ลองเช็คดูว่า จะเกิดอะไรขึ้นเมื่อเรารีเฟรชหน้าเพจด้วยตัวเอง

 Session.get('pageTitle');
null
Browser console

เมื่อเรารีโหลดหน้าเพจ เราจะเสียเซสชันไป แต่ในการทำงานแบบ HCR นั้น Meteor จะบันทึกเซสชันลงใน local storage ของเบราว์เซอร์คุณ และโหลดมันกลับมาให้อีกครั้งหลังจากการรีโหลด อย่างไรก็ตาม ผลที่ได้จากการรีโหลดด้วยตัวเองนั้นสมเหตุสมผลดี กล่าวคือ ถ้าผู้ใช้งานรีโหลดหน้าเพจเอง มันก็เหมือนกับเค้าได้เปิดเข้ามาที่ URL เดิมอีกครั้ง และค่าต่างๆก็ควรจะถูกรีเซ็ทให้กลับไปเหมือนสถานะตอนเริ่มแรกซึ่งผู้ใช้คนไหนก็เห็นได้เมื่อเข้ามาที่ URL นี้

บทเรียนสำคัญที่เราได้จากเรื่องนี้คือ

  1. พยายามเก็บข้อมูลสถานะผู้ใช้งานในเซสชัน หรือ URL อยู่เสมอ เพื่อให้ผู้ใช้ได้รับผลกระทบน้อยที่สุดเมื่อการรีโหลดอัตโนมัติเกิดขึ้น

  2. บันทึกสถานะของสิ่งที่คุณต้องการแชร์ระหว่างผู้ใช้งานด้วยกัน ไว้ใน URL

ทั้งหมดนี้คือบทสรุปที่เราได้จากการสำรวจเรื่องราวที่เกี่ยวข้องกับเซสชัน หนึ่งในคุณสมบัติเด่นของ Meteor ที่เรียกมาใช้งานได้ง่ายๆ แต่ตอนนี้ คุณต้องไม่ลืมที่จะเปลี่ยนโค้ดกลับไปเหมือนเดิม ก่อนที่จะเริ่มเรียนบทต่อไป

เพิ่มบัญชีผู้ใช้

6

ที่ผ่านมาเราได้สร้างแอพต้นแบบ โดยนำข้อมูลโพสต์ตัวอย่างมาแสดงผลออกหน้าเว็บในแบบที่ใช้ได้ดีทีเดียว

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

ถ้าอย่างนั้น ลองมาดูกันว่าจะแก้ไขเรื่องพวกนี้ได้ยังไง

บัญชีผู้ใช้: ไม่ยากอย่างที่คิด

เฟรมเวิร์คบนเว็บส่วนมาก เมื่อเราต้องการเพิ่มบัญชีผู้ใช้งานเข้าไปมักเป็นเรื่องน่าเบื่อที่ต้องเจออยู่บ่อยๆ แน่นอนว่าเราต้องทำกับแทบทุกโปรเจกต์ แต่มัันไม่เคยง่ายอย่างที่ควรจะเป็นเลย ยิ่งกว่านั้นถ้าเราต้องยุ่งกับ OAuth หรือ ระบบตรวจสอบผู้ใช้แบบอื่นๆแล้วละก็ โค้ดที่เสร็จแล้วมักจะดูไม่ได้เลย

โชคดีที่ Meteor ช่วยเราได้ ด้วยวิธีจัดการแพคเกจที่ทำให้เราใช้โค้ดร่วมกันได้ทั้งบนเซิร์ฟเวอร์ (จาวาสคริปต์) และไคลเอนต์ (จาวาสคริปต์ HTML และ CSS) นั่นก็คือเราแทบจะได้ระบบบัญชีผู้ใช้มาฟรีๆ

เราอาจเลือกใช้แพ็คเกจบัญชีผู้ใช้ที่มาพร้อมกับ Meteor (ด้วยคำสั่ง meteor add accounts-ui) แต่เนื่องจากเราสร้างแอพด้วย Bootstrap เราก็ควรใช้แพ็คเกจ ian:accounts-ui-bootstrap-3 แทน (ไม่ต้องกังวลไป ที่แตกต่างกันก็แค่สไตล์หน้าเว็บเท่านั้น) โดยให้ป้อนคำสั่งดังนี้ :

meteor add ian:accounts-ui-bootstrap-3
meteor add accounts-password
Terminal

คำสั่งทั้งสองนี้จะสร้างเทมเพลทบัญชีผู้ใช้เพิ่มให้เรา โดยเราสามารถนำมาใส่ในแอพได้ด้วยตัวช่วย {{> loginButtons}} และถ้าคุณต้องการควบคุมตำแหน่งของปุ่มล็อกอินว่าจะให้แสดงที่ด้านไหนก็แค่เพิ่มคุณสมบัติ align เข้าไป (เช่น {{> loginButtons align="right"}})

เราจะเพิ่มปุ่มนี้เข้าไปที่ส่วนหัวของหน้าแอพ และเนื่องจากส่วนหัวนี้เริ่มจะมีข้อมูลเยอะขึ้น เราก็จะสร้างเทมเพลทส่วนตัวให้มัน (วางไว้ที่ client/templtes/includes/) โดยใส่แท็กและคลาสเพิ่มเติมเข้าไปด้วย ตามที่ Bootstrap แนะนำ เพืิ่อให้ทั้งหมดดูดี :

<template name="layout">
  <div class="container">
    {{> header}}
    <div id="main">
      {{> yield}}
    </div>
  </div>
</template>
client/templates/application/layout.html
<template name="header">
  <nav class="navbar navbar-default" role="navigation">
    <div class="navbar-header">
      <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
        <span class="sr-only">Toggle navigation</span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </button>
      <a class="navbar-brand" href="{{pathFor 'postsList'}}">Microscope</a>
    </div>
    <div class="collapse navbar-collapse" id="navigation">
      <ul class="nav navbar-nav navbar-right">
        {{> loginButtons}}
      </ul>
    </div>
  </nav>
</template>
client/templates/includes/header.html

ตอนนี้เมื่อเราเปิดไปที่หน้าแอพ เราจะเห็นปุ่มล็อกอินที่มุมขวาบนของหน้าจอ

Meteor's built-in accounts UI
Meteor’s built-in accounts UI

เราสามารถใช้ปุ่มนี้ สร้างบัญชีผู้ใช้ ล็อกอิน ขอแก้รหัสผ่าน และอื่นๆ ที่แอพง่ายๆจำเป็นต้องมีเพื่อจัดการบัญชีผู้ใช้ได้

และเพื่อบอกให้ระบบบัญชีผู้ใช้รู้ว่าเราต้องการให้ผู้ใช้งานล็อกอินด้วยชื่อ เราก็แค่เพิ่มบล็อก Accounts.ui ในไฟล์ config.js ที่สร้างขึ้นใหม่ใน client/helpers/ ดังนี้ :

Accounts.ui.config({
  passwordSignupFields: 'USERNAME_ONLY'
});
client/helpers/config.js

คอมมิท 6-1

Added accounts and added template to the header

สร้างผู้ใช้คนแรก

เริ่มสร้างบัญชีผู้ใช้ใหม่กันได้เลย เมื่อทำเรียบร้อยปุ่ม “Sign in” ก็จะเปลี่ยนไปเป็นชื่อผู้ใช้งานของคุณ เพื่อยืนยันว่าบัญชีผู้ใช้ของคุณได้ถูกสร้างขึ้นแล้ว แต่ทว่าข้อมูลบัญชีนี้ถูกเก็บไว้ที่ไหนกันล่ะ

ตอนที่เราเพิ่มแพ็คเกจ account เข้าไปนั้น Meteor ก็สร้างคอลเลคชั่นใหม่ขึ้นมาหนึ่งตัว โดยสามารถเรียกใช้ได้จาก Meteor.users ซึ่งถ้าเราเปิดคอนโซลของเบราว์เซอร์และพิมพ์คำสั่งต่อไปนี้ :

 Meteor.users.findOne();
Browser console

คอนโซลก็จะแสดงอ็อบเจกต์ของผู้ใช้ที่คุณสร้างไว้ และถ้าดูตรงข้อมูลก็จะเห็นชื่อผู้ใช้งานของคุณ รวมทั้ง _id ที่มีค่าเฉพาะใช้อ้างถึงคุณได้ นอกจากนี้คุณยังสามารถใช้คำสั่ง Meteor.user() เพื่อเรียกข้อมูลผู้ใช้งานปัจจุบันที่ล็อกอินอยู่ได้ด้วย

ตอนนี้ให้คุณล็อกเอาท์ และลองสร้างบัญชีผู้ใช้ใหม่อีกรอบด้วยชื่ออื่น ซึ่งถ้าตอนนี้เราใช้คำสั่ง Meteor.user() ก็น่าจะได้ข้อมูลผู้ใช้คนที่สองนี้ออกมา แต่ช้าก่อน ลองรันคำสั่งนี้ดู :

 Meteor.users.find().count();
1
Browser console

คอนโซลคืนค่ามาเป็น 1 แต่เดี๋ยวก่อน มันควรจะเป็น 2 นี่ หรือว่ามีใครแอบลบบัญชีแรกออกไปแล้ว ถ้าคุณลองล็อกอินด้วยชื่อบัญชีแรกก็จะเห็นว่ามันยังคงใช้ได้อยู่

เพื่อให้แน่ใจเราลองมาเช็คจากที่เก็บข้อมูลเลยดีกว่า ก็ที่ฐานข้อมูล Mongo ไง โดยเราจะล็อกอินไปที่ Mongo (ด้วยคำสั่ง meteor mongo ในเทอร์มินอล) แล้วเช็คดูดังนี้ :

> db.users.count()
2
Mongo console

มันมีข้อมูลผู้ใช้อยู่ 2 คนจริงๆ แต่ว่าทำไมเราถึงเห็นแค่คนเดียวจากในเบราว์เซอร์ล่ะ

ความพิศวงของการส่งข้อมูล !

ถ้าคุณคิดย้อนกลับไปถึงบทที่ 4 คุณอาจจำได้ว่าการปิด autopublish ก็คือการที่เราห้ามไม่ให้คอลเลคชั่นที่เซิร์ฟเวอร์ส่งข้อมูลทั้งหมดมาให้คอลเลคชั่นในไคลเอนต์ที่เชื่อมต่อกันอยู่โดยอัตโนมัติ ดังนั้นเราถึงต้องสร้างช่องทางการส่งและบอกรับข้อมูลขึ้นมาเอง

แต่เราก็ยังไม่เคยกำหนดให้มีการส่งข้อมูลบัญชีผู้ใช้ด้วยซ้ำ แล้วเราเห็นข้อมูลพวกนี้ได้ยังไงกัน

คำตอบก็คือ แพ็คเกจจัดการบัญชีผู้ใช้ ทำการส่งข้อมูลรายละเอียดผู้ใช้งานปัจจุบันมาให้เองโดยอัตโนมัติ (auto-publish) เพราะถ้าไม่ทำอย่างนั้น ผู้ใช้คงไม่มีทางล็อกอินเข้าแอพได้เลย !

แต่แพ็คเกจจัดการบัญชีผู้ใช้จะส่งเฉพาะค่าของ ผู้ใช้ปัจจุบัน มาให้เท่านั้น ทำให้อธิบายได้ว่าทำไมผู้ใช้งานปัจจุบันถึงไม่เห็นข้อมูลของผู้ใช้คนอื่น

นั่นก็คือจะมีการส่งข้อมูลเฉพาะของผู้ใช้งานปัจจุบันที่ล็อกอินเข้ามาแล้วเท่านั้น และไม่ส่งอะไรมาเลยถ้าคุณไม่ได้ล็อกอิน

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

> db.users.find()
{
  "_id": "H5kKyxtbkLhmPgtqs",
  "createdAt": ISODate("2015-02-10T08:26:48.196Z"),
  "profile": {},
  "services": {
    "password": {
      "bcrypt": "$2a$10$yGPywo3/53IHsdffdwe766roZviT03YBGltJ0UG"
    },
    "resume": {
      "loginTokens": [{
        "when": ISODate("2015-02-10T08:26:48.203Z"),
        "hashedToken": "npxGH7Rmkuxcv098wzz+qR0/jHl0EAGWr0D9ZpOw="
      }]
    }
  },
  "username": "sacha"
}
Mongo console

ส่วนที่เบราว์เซอร์นั้น ข้อมูลผู้ใช้จะเหลือแค่นิดเดียว ดูได้จากคำสั่งที่คล้ายกันดังนี้ :

 Meteor.users.findOne();
Object {_id: "kYdBd9hr3fWPGPcii", username: "tmeasday"}
Browser console

ตัวอย่างนี้แสดงให้เราเห็นว่าคอลเลคชั่นที่ไคลเอนต์เป็นเพียง ชุดข้อมูลย่อย ของฐานข้อมูลจริงได้อย่างไร ผู้ใช้ที่ล็อกอินจะเห็นเพียงแค่ชุดข้อมูลที่เพียงพอต่อการใช้งานเท่านั้น (แค่ให้ล็อกอินได้) ซึ่งเราจะได้เรียนรู้รูปแบบการทำงานนี้ในโอกาสต่อไป

ทั้งหมดนี้ไม่ได้หมายความว่าคุณไม่สามารถใช้ข้อมูลอื่นจากบัญชีผู้ใช้ได้ ถ้าคุณดูที่ หน้าเอกสารของ Meteor ก็จะรู้วิธีการเลือกส่งข้อมูลอื่นๆที่ต้องการจากคอลเลคชั่น Meteor.users มาใช้งานได้

การทำงานแบบรีแอคทีฟ

Sidebar 6.5

ถ้าคอลเลคชั่นเป็นแกนคุณสมบัติหลักของ Meteor แล้ว การทำงานแบบรีแอคทีฟ (reactivity) ก็คือ เปลือกหุ้มที่ทำให้แกนนั้นใช้ประโยชน์ได้นั่นเอง

คอลเลคชั่นได้เปลี่ยนแปลงวิธีการทำงานของแอปพลิเคชันเมื่อมีการแก้ไขข้อมูลไปอย่างสิ้นเชิง โดยแทนที่แอพจะต้องตรวจสอบการเปลี่ยนแปลงของข้อมูล (เช่น ด้วยการเรียกใช้ AJAX) แล้วปรับแก้ไขไปที่หน้า HTML เอง แต่ Meteor ทำให้หน้าจอการใช้งานปรับเปลี่ยนไปตามข้อมูลที่ถูกเปลี่ยนแปลงตลอดเวลา

ลองหยุดคิดกันซักนิดจะพบว่า เบื้องหลังการทำงานนั้น Meteor สามารถที่จะเปลี่ยนแปลง ส่วนหนึ่งส่วนใด ของหน้าจอเมื่อมีการเปลี่ยนแปลงของคอลเลคชั่นเกิดขึ้นได้

วิธีการ เขียนโค้ด (imperative) เพื่อทำงานแบบนี้ เราต้องเรียกใช้ .observe() ที่เป็นฟังก์ชันของเคอร์เซอร์ ผูกเข้ากับเอกสารที่เราสนใจ เมื่อใดก็ตามที่เอกสารนั้นมีการเปลี่ยนแปลง เคอร์เซอร์ก็จะเรียกฟังก์ชัน callback ให้ทำงาน โดยเราสามารถแก้ไข DOM (โค้ด HTML ของหน้าเว็บ) ในฟังก์ชัน callback ได้ ซึ่งโค้ดที่เขียนออกมาก็จะดูคล้ายๆแบบนี้

Posts.find().observe({
  added: function(post) {
    // when 'added' callback fires, add HTML element
    $('ul').append('<li id="' + post._id + '">' + post.title + '</li>');
  },
  changed: function(post) {
    // when 'changed' callback fires, modify HTML element's text
    $('ul li#' + post._id).text(post.title);
  },
  removed: function(post) {
    // when 'removed' callback fires, remove HTML element
    $('ul li#' + post._id).remove();
  }
});

คุณก็พอจะมองออกได้ว่าโค้ดที่ใช้จะซับซ้อนขึ้นอย่างรวดเร็วแค่ไหน ลองคิดดูว่าถ้าต้องเปลี่ยนค่าแต่ละ ฟิลด์ ของข่าว และยังต้องปรับแก้ไข HTML ที่ซับซ้อนในแท็ก <li> ของข่าวนั้นด้วย นี่ยังไม่ได้พูดถึงในกรณีที่มันซับซ้อนสุดๆ เมื่อเราเริ่มผูกแหล่งข้อมูลหลายๆตัวที่เปลี่ยนแปลงค่าได้แบบเรียลไทม์เลยนะ

เรา ควร ใช้ observe() ตอนไหน

การใช้รูปแบบการโค้ดข้างบนนั้นบางทีก็จำเป็น โดยเฉพาะเมื่อต้องเกี่ยวข้องกับโค้ดจากที่อื่น (third-party widgets) ตัวอย่างเช่น ถ้าเราต้องการปักหรือถอนหมุดบนแผนที่แบบเรียลไทม์ด้วยข้อมูลจากคอลเลคชั่น (เพื่อแสดงตำแหน่งที่ผู้ใช้ล็อกอินเข้ามา)

ในกรณีนี้ เราจำเป็นต้องใช้ observe() เพื่อทำให้แผนที่ “คุย” กับคอลเลคชั่นใน Meteor ได้ และรู้ว่าจะทำอย่างไรเมื่อข้อมูลเปลี่ยนไป เช่น คุณอาจจะใช้ฟังก์ชัน callback added และ removed เพื่อเรียกเมธอด dropPin() หรือ removePin() จาก API ของแผนที่

วิธีการแบบ Declarative

Meteor มีวิธีการที่ดีกว่านั้นให้เราก็คือ การทำงานแบบรีแอคทีฟ ซึ่งแกนหลักของมันก็คือ วิธีการแบบ declarative โดยวิธีนี้จะให้เรากำหนดความสัมพันธ์ระหว่างวัตถุในครั้งแรก และเราแค่รู้ว่าพวกมันจะมีค่าสอดคล้องกันอยู่เสมอ แทนที่จะต้องกำหนดว่าจะต้องทำอะไรเมื่อมีการเปลี่ยนแปลงเกิดขึ้น

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

ทั้งหมดนี้ก็เพื่อจะบอกว่า แทนที่จะคิดถึงการใช้งาน observe แต่ Meteor ให้เราเขียนแค่นี้

<template name="postsList">
  <ul>
    {{#each posts}}
      <li>{{title}}</li>
    {{/each}}
  </ul>
</template>

และดึงข้อมูลข่าวออกมาด้วยโค้ด

Template.postsList.helpers({
  posts: function() {
    return Posts.find();
  }
});

สิ่งที่เกิดขึ้นเบื้องหลังก็คือ Meteor จะทำหน้าที่เชื่อมฟังก์ชัน callback ต่างๆใน observe() ให้เรา และจัดการวาดส่วนต่างๆของ HTML เมื่อข้อมูลรีแอกทีฟมีการเปลี่ยนแปลง

ส่วนประมวลผล

แม้ว่า Meteor จะเป็นเฟรมเวิร์กแบบเรียลไทม์และรีแอกทีฟ แต่ก็ไม่ใช่ทั้งหมดในแอพ Meteor ที่ทำงานแบบรีแอกทีฟ เพราะถ้าเป็นแบบนั้น ทุกส่วนในแอพของคุณก็จะต้องทำงานใหม่ทุกครั้งที่มีบางอย่างเปลี่ยนแปลง การทำงานแบบรีแอกทีฟจึงถูกจำกัดให้อยู่ในพื้นที่เฉพาะของโค้ดคุณ และเราเรียกพื้นที่นี้ว่า ส่วนประมวลผล (computations)

ในอีกความหมายหนึ่ง ส่วนประมวลผล ก็คือ บล็อกของโค้ดซึ่งทำงานทุกๆครั้งเมื่อแหล่งข้อมูลชนิดรีแอกทีฟที่ผูกกันอยู่มีการเปลี่ยนแปลง ถ้าคุณมีแหล่งข้อมูลชนิดรีแอกทีฟ (เช่น ตัวแปรเซสชัน) และอยากให้มีการตอบสนองแบบรีแอกทีฟจากมัน คุณก็จำเป็นต้องสร้างส่วนประมวลผลให้

จำไว้ว่าปกติคุณไม่จำเป็นต้องทำแบบนี้ เพราะ Meteor กำหนดให้เทมเพลตและตัวช่วยมีส่วนประมวลผลพิเศษของตัวเองอยู่แล้ว (ความหมายคือ คุณมั่นใจได้เลยว่าเทมเพลตของคุณจะตอบสนองแบบรีแอกทีฟต่อแหล่งข้อมูลของมันแน่นอน)

แหล่งข้อมูลชนิดรีแอกทีฟทุกตัวจะติดตามส่วนประมวลผลทั้งหมดที่เรียกใช้มัน โดยมันจะแจ้งไปที่ส่วนประมวลผลเหล่านั้นเมื่อตัวมันเองมีค่าเปลี่ยนแปลงไป ด้วยการเรียกใช้ฟังก์ชัน invalidate() กับส่วนประมวลผล

โดยทั่วไปแล้ว ส่วนประมวลผลทั้งหมดถูกกำหนดให้ทำงานแค่ประมวลผลใหม่อีกครั้ง เมื่อข้อมูลข้างในถูกทำให้ใช้การไม่ได้ (invalidation) และนั่นก็คือสิ่งที่เกิดขึ้นกับส่วนประมวลผลของเทมเพลตทั้งหมด (ถึงแม้ว่าส่วนประมวลผลของเทมเพลตจะใช้วิธีการพิเศษบางอย่างเพื่อวาดหน้าเพจขึ้นใหม่อย่างมีประสิทธิภาพด้วยก็ตาม) แม้คุณจะสามารถควบคุมได้ว่าจะให้ส่วนประมวลผลของคุณทำอะไรได้บ้างในเวลานั้นถ้าคุณจำเป็น แต่ในทางปฏิบัติแล้ว การประมวลผลใหม่อีกครั้ง มักเป็นวิธีที่คุณนำไปใช้อยู่เสมอ

การสร้างส่วนประมวลผล

เมื่อเราเข้าใจทฤษฎีเบื้องหลังการทำงานของส่วนประมวลผลแล้ว เพื่อให้เข้าใจมันดีขึ้นเราจะสร้างขึ้นมาซักตัว โดยนำฟังก์ชัน Tracker.autorun มาหุ้มที่บล็อกของโค้ดตรงส่วนที่เราจะใช้เป็นพื้นที่ประมวลผล และทำให้มันรีแอกทีฟดังนี้

Meteor.startup(function() {
  Tracker.autorun(function() {
    console.log('There are ' + Posts.find().count() + ' posts');
  });
});

สังเกตุว่า เราจำเป็นต้องหุ้มบล็อก Tracker ด้วยบล็อก Meteor.startup() เพื่อให้แน่ใจว่ามันจะทำงานหลังจากที่ Meteor ได้โหลดคอลเลคชั่น Posts เรียบร้อยแล้ว

เบื้องหลังการทำงานก็คือ autorun จะสร้างส่วนประมวลผลขึ้น และผูกมันเข้าเพื่อให้มันทำงานใหม่ทุกครั้งเมื่อใดก็ตามที่แหล่งข้อมูลที่เชื่อมโยงกันมีการเปลี่ยนแปลง ที่เราได้สร้างไปก็คือ ส่วนประมวลผลแบบง่ายๆ ที่ทำงานแค่พิมพ์จำนวนข่าวออกไปที่คอนโซล และเนื่องจาก Posts.find() เป็นแหล่งข้อมูลชนิดรีแอกทีฟ มันก็จะคอยแจ้งส่วนประมวลผลให้ทำการประมวลผลใหม่ทุกครั้งเมื่อจำนวนข่าวเปลี่ยนแปลงไป

> Posts.insert({title: 'New Post'});
There are 4 posts.

ที่เราได้รู้มาทั้งหมดนี้ ทำให้เราสามารถเขียนโค้ดที่ใช้ข้อมูลชนิดรีแอกทีฟได้อย่างเป็นธรรมชาติ และมั่นใจได้ว่าเบื้องหลังการทำงานนั้น ระบบติดตามความเชื่อมโยงจะช่วยจัดการให้มีการทำงานใหม่ทุกครั้งในเวลาที่เหมาะสมได้

สร้างข่าวใหม่

7

เราก็เห็นกันแล้วว่า การสร้างข่าวใหม่จากคอนโซลนั้นง่ายแค่ไหน ด้วยการเรียกใช้คำสั่ง Posts.insert ของฐานข้อมูล แต่เราก็คาดหวังไม่ได้ว่า ผู้ใช้จะเปิดคอนโซลแล้วสร้างข่าวใหม่เข้าไปเอง

ในที่สุดแล้วเราก็จำเป็นต้องสร้างส่วนติดต่อผู้ใช้บางอย่าง เพื่อช่วยให้ผู้ใช้งานสามารถโพสต์ข่าวเข้าไปที่แอพเราได้

สร้างหน้าโพสต์ข่าว

เราเริ่มต้นด้วยการสร้างเส้นทางไปที่หน้าใหม่ของเรา

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});

Router.route('/posts/:_id', {
  name: 'postPage',
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/submit', {name: 'postSubmit'});

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
lib/router.js

เพิ่มลิงก์ที่ส่วนหัว

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

<template name="header">
  <nav class="navbar navbar-default" role="navigation">
    <div class="container-fluid">
      <div class="navbar-header">
        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
        <a class="navbar-brand" href="{{pathFor 'postsList'}}">Microscope</a>
      </div>
      <div class="collapse navbar-collapse" id="navigation">
        <ul class="nav navbar-nav">
          <li><a href="{{pathFor 'postSubmit'}}">Submit Post</a></li>
        </ul>
        <ul class="nav navbar-nav navbar-right">
          {{> loginButtons}}
        </ul>
      </div>
    </div>
  </nav>
</template>
client/templates/includes/header.html

เส้นทางที่เรากำหนดยังหมายถึงว่า ถ้าผู้ใช้เปิดเข้าไปที่พาธ /submit Meteor ก็จะแสดงเทมเพลต postSubmit ด้วย ดังนั้นเราก็จะมาเขียนเทมเพลตนี้กัน

<template name="postSubmit">
  <form class="main form page">
    <div class="form-group">
      <label class="control-label" for="url">URL</label>
      <div class="controls">
          <input name="url" id="url" type="text" value="" placeholder="Your URL" class="form-control"/>
      </div>
    </div>
    <div class="form-group">
      <label class="control-label" for="title">Title</label>
      <div class="controls">
          <input name="title" id="title" type="text" value="" placeholder="Name your post" class="form-control"/>
      </div>
    </div>
    <input type="submit" value="Submit" class="btn btn-primary"/>
  </form>
</template>
client/templates/posts/post_submit.html

จะเห็นว่า มีการใช้มาร์คอัพพอสมควรในเทมเพลต เหตุผลง่ายๆก็เพราะเราใช้ Twitter Bootstrap ซึ่งนอกจากส่วนประกอบที่จำเป็นของฟอร์มแล้ว มาร์คอัพพิเศษพวกนี้ก็จะช่วยให้แอพเราดูดีขึ้นด้วย และมันควรจะออกมาคล้ายๆ แบบนี้

The post submit form
The post submit form

เนื่องจากฟอร์มนี้เป็นแบบง่ายๆ เราจึงไม่ต้องกังวลกับการทำงานของมันนัก เพราะว่ายังไงเราก็จะดักจับเหตุการณ์ตอน submit และอัพเดทข้อมูลด้วยจาวาสคริปต์อยู่แล้ว (และก็ไม่มีเหตุผลที่จะต้องหาทางออกในกรณีไม่มีจาวาสคริปต์ เพราะถ้าคุณพิจารณาดูจะเห็นว่าแอพ Meteor ทำงานไม่ได้อยู่แล้วถ้าจาวาสคริปต์ไม่ทำงาน)

สร้างข่าวใหม่

ได้เวลาที่จะจัดการกับเหตุการณ์ submit ของฟอร์มกันแล้ว วิธีที่ดีที่สุดคือ ผูกโค้ดเข้ากับเหตุการณ์ submit โดยตรง (จะดีกว่าผูกเข้ากับเหตุการณ์ click ของปุ่ม) ซึ่งจะครอบคลุมการ submit ที่อาจเกิดขึ้นได้ทั้งหมด (เช่น กดปุ่ม enter เป็นต้น)

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    };

    post._id = Posts.insert(post);
    Router.go('postPage', post);
  }
});
client/templates/posts/post_submit.js

คอมมิท 7-1

Added a submit post page and linked to it in the header.

โดยฟังก์ชันนี้ใช้ jQuery เพื่อแปลงค่าที่ได้จากฟิลด์ต่างๆในฟอร์ม และสร้างอ็อบเจกต์โพสต์จากค่าเหล่านั้น ที่เราต้องเรียก preventDefault จากพารามิเตอร์ event ก็เพื่อให้แน่ใจว่า เบราว์เซอร์จะไม่พยายาม submit เอง

ในช่วงท้ายของโค้ด เราก็สามารถสั่งให้ตัวจัดการเส้นทางส่งเราไปที่หน้าข่าวที่เพิ่งสร้างใหม่ได้ ทั้งนี้ฟังก์ชัน insert() ที่กระทำกับคอลเลคชั่นจะคืนค่า _id ของอ็อบเจกต์ใหม่ที่เพิ่งถูกแทรกเข้าไปในฐานข้อมูล ซึ่งฟังก์ชัน go() ของตัวจัดการเส้นทางจะนำไปสร้างพาธให้เราเปิดเข้าไปใช้งานได้

ผลลัพธ์ที่ได้เมื่อผู้ใช้กดปุ่ม submit ก็คือ ข่าวใหม่ถูกสร้างขึ้น และผู้ใช้ถูกพาเข้าไปที่หน้าสนทนาของข่าวที่สร้างใหม่นั้นทันที

เพิ่มความปลอดภัยเข้าไป

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

ต้องขอบคุณระบบรักษาความปลอดภัยข้อมูลที่ถูกสร้างรวมมากับคอลเลคชั่น Meteor ตั้งแต่แรก แต่มันถูกปิดไว้ตามค่าตั้งต้นตอนที่คุณสร้างโปรเจกต์ใหม่ เพื่อช่วยให้คุณเริ่มสร้างแอพได้ง่ายขึ้น โดยทิ้งเรื่องน่าเบื่อต่างๆไว้ทีหลัง

ตอนนี้แอพเราก็ไม่ต้องการตัวช่วยพวกนี้อีกแล้ว ดังนั้นก็ปิดมันซะเลย! โดยการถอนแพ็คเกจ insecure ออกมาดังนี้

meteor remove insecure
Terminal

หลังจากทำตรงนี้ คุณจะสังเกตุว่า ฟอร์มป้อนข่าวใหม่ใช้งานได้ไม่ดีเหมือนเดิม นั่นก็เพราะว่า เมื่อไม่มีแพ็คเกจ insecure แล้ว การเพิ่มข้อมูลข่าวเข้าไปในคอลเลคชั่นที่ไคลเอนต์จะ ไม่สามารถทำได้อีก

และเราจำเป็นที่จะต้องกำหนดกฏเกณฑ์ชัดเจนให้ Meteor รู้ว่า มันโอเคที่จะยอมให้ไคลเอนต์เพิ่มข้อมูลเข้ามาได้ หรือว่าเราจะทำการเพิ่มข้อมูลที่ฝั่งเซิร์ฟเวอร์แทน

ยอมให้เพิ่มข่าวได้

เริ่มต้นด้วยการที่เราจะแสดงให้ดูว่า ทำอย่างไรจึงจะยอมให้ไคลเอนต์สามารถเพิ่มข่าวได้ เพื่อที่จะให้ฟอร์มของเราทำงานได้อีกครั้ง ซึ่งในท้ายที่สุดแล้วเราอาจจะจบลงด้วยเทคนิคอีกแบบนึง แต่ทว่าตอนนี้ลองทำตามแบบง่ายๆนี้ดูก่อน

Posts = new Mongo.Collection('posts');

Posts.allow({
  insert: function(userId, doc) {
    // only allow posting if you are logged in
    return !! userId;
  }
});
lib/collections/posts.js

คอมมิท 7-2

Removed insecure, and allowed certain writes to posts.

เราเรียกใช้ Posts.allow เพื่อบอกให้ Meteor รู้ว่า “สิ่งนี้คือชุดเหตุการณ์ซึ่งเรายอมให้ไคลเอนต์ทำอะไรกับคอลเลคชั่น Posts ได้บ้าง” โดยในกรณีนี้ เราก็บอกว่า “เรายอมให้ไคลเอนต์เพิ่มข่าวใหม่เข้าไปตราบเท่าที่พวกเค้ามี userID

ค่า userId ของผู้ใช้ตอนที่กำลังแก้ไขข้อมูลจะถูกส่งต่อไปที่ฟังก์ชัน allow และ deny (หรือมีค่าเป็น null ถ้าผู้ใช้ยังไม่ได้ล็อกอิน) ซึ่งนำไปใช้ประโยชน์ต่อได้ และในเมื่อบัญชีผู้ใช้ก็เป็นส่วนหนึ่งของ Meteor ด้วยแล้ว เราจึงมั่นใจได้ว่า userId จะมีค่าที่ถูกต้องเสมอ

และเราก็ได้ทำให้แน่ใจแล้วว่า คุณจำเป็นต้องล็อกอินก่อนถึงจะสร้างข่าวใหม่ได้ ถึงตรงนี้ลองล็อกเอาท์และสร้างข่าวใหม่ดู คุณจะเห็นข้อความแบบนี้ขึ้นที่คอนโซล

Insert failed: Access denied
Insert failed: Access denied

แต่อย่างไรก็ตาม ยังมีเรื่องที่เราต้องจัดการอีกสองสามอย่างคือ

  • ผู้ใช้ที่ล็อกเอาท์ ยังคงเข้าไปที่หน้าฟอร์มสร้างข่าวได้
  • ข่าวที่สร้างใหม่ ก็ไม่ได้มีความเกี่ยวข้องอะไรกับผู้ใช้เลย (และยังไม่มีโค้ดฝั่งเซิร์ฟเวอร์ที่จัดการตรงนี้ด้วย)
  • ข่าวใหม่ๆ สามารถสร้างด้วย URL เดียวกันได้

เราจะมาแก้ไขเรื่องพวกนี้กัน

ป้องกันฟอร์มสร้างข่าว

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

ฮุคนี้จะดักจับการจัดเส้นทาง และสามารถเปลี่ยนการทำงานของตัวจัดการเส้นทางได้ เปรียบได้กับเจ้าหน้าที่รักษาความปลอดภัยกำลังตรวจสอบข้อมูลคุณ ก่อนที่จะยอมให้คุณเข้าไปข้างใน (หรือไล่คุณกลับไป)

สิ่งที่เราต้องทำคือ ตรวจสอบว่าผู้ใช้ล็อกอินเข้ามาแล้วหรือยัง ถ้ายังไม่ล็อกอิน ให้แสดงเทมเพลต accessDenied แทนเทมเพลต postSubmit ตามปกติ (และเราก็จะสั่งให้ตัวจัดการเส้นทางหยุดทำงานอื่นๆต่อจากนั้น) ดังนั้นเราก็มาแก้ไขไฟล์ router.js กันดังนี้

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});

Router.route('/posts/:_id', {
  name: 'postPage',
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/submit', {name: 'postSubmit'});

var requireLogin = function() {
  if (! Meteor.user()) {
    this.render('accessDenied');
  } else {
    this.next();
  }
}

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
lib/router.js

และเราก็ต้องสร้างเทมเพลตของหน้า access denied นี้ด้วย

<template name="accessDenied">
  <div class="access-denied page jumbotron">
    <h2>Access Denied</h2>
    <p>You can't get here! Please log in.</p>
  </div>
</template>
client/templates/includes/access_denied.html

คอมมิท 7-3

Denied access to new posts page when not logged in.

ตอนนี้ถ้าคุณเข้าไปที่ http://localhost:3000/submit/ โดยไม่ได้ล็อกอิน คุณก็ควรจะเห็นข้อความคล้ายๆแบบนี้

The access denied template
The access denied template

ข้อดีของการใช้ฮุคที่เส้นทางคือ พวกมันทำงานแบบ รีแอกทีฟ หมายความว่า เราไม่ต้องคิดเรื่องการใช้ฟังก์ชัน callback เมื่อมีผู้ใช้ล็อกอินเข้ามา เพราะเมื่อสถานะล็อกอินของผู้ใช้เปลี่ยนไป เทมเพลตที่ตัวจัดการเส้นทางใช้ในหน้านั้นก็จะเปลี่ยนจาก accessDenied เป็น postSubmit ทันที โดยเราไม่ต้องเขียนโค้ดจัดการใดๆทั้งสิ้น (และอย่าลืมว่า มันทำงานข้ามแท็บของเบราว์เซอร์ได้ด้วย)

ลองล็อกอิน และรีเฟรชหน้านี้ดู คุณอาจจะเห็นหน้า access denied กระพริบขึ้นมาแว่บนึงก่อนเข้าหน้าป้อนข่าว เหตุผลที่เป็นอย่างนี้ก็เพราะ Meteor จะเริ่มวาดเทมเพลททันทีที่เป็นไปได้ ก่อนที่มันจะติดต่อกับเซิร์ฟเวอร์ และเช็ค (จากค่าที่เก็บในข้อมูลของเบราว์เซอร์) ว่า ผู้ใช้คนปัจจุบันมีตัวตนซะอีก

เพื่อหลีกเลี่ยงปัญหานี้ (ที่เป็นปัญหาหลักซึ่งคุณจะพบมากขึ้น เมื่อคุณต้องเผชิญกับความล่าช้าระหว่างไคลเอนต์และเซิร์ฟเวอร์) เราก็จะแสดงหน้ากำลังโหลดชั่วครู่ เพื่อรอการตรวจสอบว่าผู้ใช้งานมีสิทธิเข้าใช้หรือไม่

จริงๆแล้วในตอนนี้เราก็ไม่รู้ว่า ผู้ใช้มีสิทธิการใช้งานที่ถูกต้องหรือไม่ ทำให้เราไม่สามารถแสดงได้ทั้งเทมเพลต accessDenied และ postSubmit จนกว่าเราจะรู้

ดังนั้นเราก็จะแก้ไขฮุคของเราให้เปลี่ยนมาใช้เทมเพลทแสดงการโหลด เมื่อ Meteor.loggingIn() มีค่าเป็นจริง

//...

var requireLogin = function() {
  if (! Meteor.user()) {
    if (Meteor.loggingIn()) {
      this.render(this.loadingTemplate);
    } else {
      this.render('accessDenied');
    }
  } else {
    this.next();
  }
}

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
lib/router.js

คอมมิท 7-4

Show a loading screen while waiting to login.

การซ่อนลิงก์

วิธีการง่ายที่สุด ที่จะป้องกันผู้ใช้เข้ามาที่หน้านี้โดยบังเอิญเมื่อล็อกเอาท์ออกไปแล้ว ก็คือ ซ่อนลิงก์ไม่ให้เห็น ซึ่งเราทำได้ง่ายๆดังนี้

//...

<ul class="nav navbar-nav">
  {{#if currentUser}}<li><a href="{{pathFor 'postSubmit'}}">Submit Post</a></li>{{/if}}
</ul>

//...
client/templates/includes/header.html

คอมมิท 7-5

Only show submit post link if logged in.

ตัวช่วย currentUser ถูกเตรียมไว้ให้เราโดยแพ็คเกจ accounts ซึ่งเป็นโค้ดของ Spacebars ที่มีค่าเหมือนกับคำสั่ง Meteor.user() และเนื่องจากมันเป็นรีแอกทีฟ ลิงก์นี้ก็จะแสดงหรือหายไปเมื่อคุณล็อกอินเข้ามา หรือล็อกเอาท์ออกจากแอพ

เมธอดของ Meteor : ง่ายและปลอดภัยขึ้น

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

  • บันทึกเวลาที่ป้อนข่าว
  • ทำให้แน่ใจว่า ไม่สามารถป้อนข่าวใหม่ด้วย URL เดียวกันได้
  • เพิ่มข้อมูลรายละเอียดเกี่ยวกับผู้สร้างข่าว (ไอดี, ชื่อผู้ใช้, และอื่นๆ)

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

  • การบันทึกเวลาที่ป้อนข่าว ด้วยเวลาที่ได้จากเครื่องของผู้ใช้ ไม่ใช่สิ่งที่ถูกต้องเสมอไป
  • ไคลเอนต์ไม่รู้ URL ทั้งหมด ที่ป้อนเข้าไปเก็บในระบบ มันจะรู้จักเฉพาะข่าวตัวที่มันสามารถเห็นได้ในตอนนี้เท่านั้น (หลังจากนี้ เราจะมาดูว่ามันเกิดขึ้นได้อย่างไร) ดังนั้นจึงไม่มีทางที่จะทำให้ URL ไม่ซ้ำกันได้
  • สุดท้าย ถึงแม้เราสามารถเพิ่มรายละเอียดของผู้ใช้ที่ฝั่งไคลเอนต์ได้ แต่เราก็ไม่สามารถควบคุมความถูกต้องของมันได้ ซึ่งอาจทำให้แอพเราเสี่ยงต่อการถูกล้วงข้อมูลไปใช้ด้วยคอนโซลของเบราว์เซอร์ได้

ด้วยเหตุผลเหล่านี้ มันจึงดีกว่าถ้าเราจะทำให้ฟังก์ชันจัดการเหตุการณ์ทำงานพื้นฐานง่ายๆ และถ้าต้องการทำอะไรที่มากกว่าการเพิ่มหรืออัพเดทคอลเลคชั่น เราก็ควรใช้ เมธอด (method)

เมธอดของ Meteor เป็นฟังก์ชันบนฝั่งเซิร์ฟเวอร์ที่ถูกเรียกใช้จากฝั่งไคลเอนต์ ซึ่งไม่ใช่ว่าเราจะไม่คุ้นเคยกับมันเลย อันที่จริงแล้วฟังก์ชัน insert, update และ remove ของคอลเลคชั่น ต่างก็เป็นเมธอดที่ทำงานอยู่เบื้องหลังทั้งหมด ถึงตอนนี้เราจะลองมาสร้างของเราเองบ้าง

ย้อนกลับไปที่ post_submit.js แทนที่เราจะเพิ่มข่าวเข้าไปตรงๆที่คอลเลคชั่น Posts เราก็จะเรียกใช้เมธอด postInsert แทน

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    };

    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return alert(error.reason);

      Router.go('postPage', {_id: result._id});  
    });
  }
});
client/templates/posts/post_submit.js

ฟังก์ชัน Method.call จะเรียกใช้งานเมธอดตามชื่อในพารามิเตอร์ตัวแรก ซึ่งคุณสามารถส่งพารามิเตอร์เพิ่มเติมตามไปได้ (ในกรณีนี้คือ อ็อบเจกต์ post ที่เราสร้างจากฟอร์ม) และใส่ฟังก์ชัน callback เป็นตัวสุดท้าย ซึ่งจะทำงานเมื่อเมธอดฝั่งเซิร์ฟเวอร์ทำงานแล้วเสร็จ

ฟังก์ชัน callback ของเมธอด Meteor จะมีพารามิเตอร์สองตัวเสมอ คือ error และ result ไม่ว่าด้วยเหตุผลอะไรก็ตาม ถ้าตัวแปร error มีค่า เราจะแจ้งเตือนผู้ใช้ (ด้วยการใช้ return เพื่อยกเลิก callback) แต่ถ้าทุกอย่างทำงานตามที่ควรจะเป็น เราก็จะส่งผู้ใช้ไปที่หน้าสนทนาของข่าวที่เพิ่งสร้างนี้

ตรวจสอบความปลอดภัย

เราจะถือโอกาสนี้เพิ่มความปลอดภ้ยให้กับเมธอดของเราด้วยการใช้แพ็คเกจ audit-argument-checks

โดยแพ็คเกจนี้จะตรวจสอบอ็อบเจกต์จาวาสคริปต์กับรูปแบบที่กำหนดไว้แล้ว ในกรณีของเรานั้น เราจะใช้มันตรวจสอบว่า ผู้ใช้ที่เรีียกใช้เมธอดนั้นล็อกอินเข้าระบบอย่างถูกต้อง (ด้วยการตรวจสอบว่า Meteor.userId() มีค่าเป็น string) และอ็อบเจกต์ postAttributes ที่ถูกส่งเข้ามาทางพารามิเตอร์ของเมธอด มีค่าคุณสมบัติ title และ url มาด้วย เพื่อป้องกันไม่ให้เราป้อนข้อมูลมั่วๆเข้าไปในฐานข้อมูล

ดังนั้นเราจะสร้างเมธอด postInsert ไว้ในไฟล์ collections/posts.js ของเรา และเราจะลบบล็อก allow() ออกจาก posts.js เนื่องจากเมธอดของ Meteor จะมองข้ามมันไปอยู่ดี

จากนั้นเราก็จะ extend อ็อบเจกต์ postAttributesโดยเพิ่มคุณสมบัติเข้าไปอีกสามตัวคือ _id ของผู้ใช้ และ username รวมทั้งเวลาที่ป้อนข่าว submitted ก่อนที่เราจะเพิ่มข้อมูลทั้งหมดนี้เข้าไปในฐานข้อมูลของเรา และส่งคืนค่า _id กลับไปให้ไคลเอนต์ (หรืออีกนัยหนึ่งคือ ผู้เรียกใช้งานเมธอดนี้) ในรูปแบบอ็อบเจกต์จาวาสคริปต์

Posts = new Mongo.Collection('posts');

Meteor.methods({
  postInsert: function(postAttributes) {
    check(Meteor.userId(), String);
    check(postAttributes, {
      title: String,
      url: String
    });

    var user = Meteor.user();
    var post = _.extend(postAttributes, {
      userId: user._id, 
      author: user.username, 
      submitted: new Date()
    });

    var postId = Posts.insert(post);

    return {
      _id: postId
    };
  }
});
lib/collections/posts.js

จำไว้ว่า เมธอด _extend() เป็นส่วนหนึ่งของไลบรารี่ Underscore และช่วยให้คุณ “extend” อ็อบเจกต์ตัวนึงด้วยคุณสมบัติของอีกตัวได้

คอมมิท 7-6

Use a method to submit the post.

ลาก่อน Allow/Deny

เมธอด Meteor จะทำงานบนเซิร์ฟเวอร์ ดังนั้น Meteor จึงสมมุติว่า เมธอดเหล่านี้เชื่อถือได้ ด้วยเหตุนี้เมธอด Meteor จึงข้ามการทำงานของฟังก์ชัน allow/deny ไป

ถ้าคุณต้องการรันโค้ดบางอย่างก่อน insert,update หรือ remove แม้จะอยู่บนเซิร์ฟเวอร์ เราก็แนะนำให้ใช้แพ็คเกจ collection-hooks

ป้องกันไม่ให้ซ้ำ

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

Meteor.methods({
  postInsert: function(postAttributes) {
    check(this.userId, String);
    check(postAttributes, {
      title: String,
      url: String
    });

    var postWithSameLink = Posts.findOne({url: postAttributes.url});
    if (postWithSameLink) {
      return {
        postExists: true,
        _id: postWithSameLink._id
      }
    }

    var user = Meteor.user();
    var post = _.extend(postAttributes, {
      userId: user._id, 
      author: user.username, 
      submitted: new Date()
    });

    var postId = Posts.insert(post);

    return {
      _id: postId
    };
  }
});
collections/posts.js

เราทำได้ด้วยการค้นหาข่าวจากฐานข้อมูลด้วย URL ที่ป้อนเข้ามา ถ้าเราพบ เราจะ return ค่า _id ของข่าวพร้อมด้วยค่า postExists: true เพื่อให้ไคลเอนต์รู้ว่าเป็นสถานะการพิเศษ

และเนื่องจากเรา return กลับไป เมธอดก็จะหยุดทำงานที่ตรงนั้น โดยไม่ทำการ insert ซึ่งก็คือการป้องกันไม่ให้ข้อมูลซ้ำอย่างนุ่มนวล

สิ่งที่เหลือก็คือการใช้ข้อมูล postExists ที่ตัวช่วยจัดการเหตุการณ์ในฝั่งไคลเอนต์ มาแสดงข้อความเตือน

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    };

    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return alert(error.reason);

      // show this result but route anyway
      if (result.postExists)
        alert('This link has already been posted');

      Router.go('postPage', {_id: result._id});  
    });
  }
});
client/templates/posts/post_submit.js

คอมมิท 7-7

Enforce post URL uniqueness.

จัดเรียงข่าว

ตอนนี้เราก็มีข้อมูลวันที่เราป้อนข่าวของข่าวทั้งหมดแล้ว มันก็สมเหตุเหตุผลที่เราจะจัดเรียงข้อมูลตามแอททริบิวท์นี้ วิธีทำเราก็แค่ใช้ตัวดำเนินการ sort ของ Mongo ซึ่งรับค่าเป็นอ็อบเจกต์ที่มีคีย์เป็นชื่อที่จะนำมาจัดเรียง และข้อมูลเป็นเครื่องหมายแสดงทิศทางจากน้อยไปมาก หรือมากไปน้อย

Template.postsList.helpers({
  posts: function() {
    return Posts.find({}, {sort: {submitted: -1}});
  }
});
client/templates/posts/posts_list.js

คอมมิท 7-8

Sort posts by submitted timestamp.

แม้จะต้องเสียเวลาไปบ้าง แต่เราก็ได้หน้าจอที่ยอมให้ผู้ใช้ป้อนข้อมูลเข้าไปในแอพได้อย่างปลอดภัย

ส่วนใหญ่แล้วแอพตัวใดก็ตามที่ยอมให้ผู้ใช้ป้อนข้อมูลเข้าไปได้ จำเป็นต้องให้วิธีแก้ไขหรือลบข้อมูลมาด้วย และนั่นคือสิ่งที่เราจะทำกันในบทถัดไป

การชดเชยความล่าช้า

Sidebar 7.5

ในบทที่ผ่านมา เราได้แนะนำแนวคิดใหม่ในโลกของ Meteor นั่นก็คือ เมธอด

Without latency compensation
Without latency compensation

เมธอดของ Meteor คือ วิธีการทำงานของชุดคำสั่งบนเซิร์ฟเวอร์ที่มีแบบแผน ในตัวอย่างของเรานั้น เราใช้เมธอดเพื่อต้องการมั่นใจว่า ข่าวที่ป้อนใหม่จะถูกแท็กด้วยชื่อและ id ของผู้สร้าง รวมทั้งเวลาปัจจุบันของเซิร์ฟเวอร์

อย่างไรก็ตาม ถ้า Meteor เรียกใช้เมธอดด้วยวิธีปกติ เราก็จะพบปัญหาแน่นอน ลองพิจารณาลำดับเหตุการณ์ข่างล่างนี้ดู (เวลาที่สุ่มขึ้นมาใช้เพื่อการอธิบายเท่านั้น)

  • +0ms: ผู้ใช้คลิ๊กที่ปุ่ม submit และเบราว์เซอร์เรียกใช้เมธอด
  • +200ms: เซิร์ฟเวอร์ปรับแก้ไขฐานข้อมูล Mongo
  • +500ms: ไคลเอนต์รับค่าการเปลี่ยนแปลง และอัพเดทหน้าจอตามการเปลี่ยนแปลงนั้น

ถ้า Meteor ทำงานด้วยวิธีแบบนี้ มันก็จะทำให้เกิดความล่าช้าระหว่างการทำงานกับการแสดงผล (ความล่าช้าจะมากหรือน้อย ขึ้นอยู่กับว่า คุณอยู่ใกล้เซิร์ฟเวอร์แค่ไหน) ซึ่งเราปล่อยให้เกิดขึ้นกับเว็บแอพสมัยใหม่ไม่ได้

การชดเชยความล่าช้า

With latency compensation
With latency compensation

เพื่อหลีกเลี่ยงปัญหานี้ Meteor ก็นำเสนอแนวคิดใหม่ที่เรียกว่า การชดเชยความล่าช้า เมื่อเราสร้างเมธอด post ขึ้นมานั้น เราใส่มันไว้ในไฟล์ที่อยู่ในโฟลเดอร์ collections/ นั่นก็หมายความว่า มันถูกเรียกใช้ได้จากทั้งเซิร์ฟเวอร์ และไคลเอนต์ และมันก็รันทั้งสองฝั่งในเวลาเดียวกันด้วย!

เมื่อคุณเรียกใช้เมธอด ไคลเอนต์จะส่งคำสั่งนั้นไปที่เซิร์ฟเวอร์ และในขณะเดียวกันก็จะ จำลอง การทำงานของเมธอดกับคอลเลคชั่นที่ไคลเอนต์ไปพร้อมๆกันด้วย ดังนั้นการทำงานของเราก็จะกลายเป็นแบบนี้

  • +0ms: ผู้ใช้คลิ๊กที่ปุ่ม submit และเบราว์เซอร์เรียกใช้เมธอด
  • +0ms: ไคลเอนต์จำลองการทำงานของเมธอดกับคอลเลคชันที่ไคลเอนต์ และปรับหน้าจอตามผลการทำงาน
  • +200ms: เซิร์ฟเวอร์ปรับแก้ไขฐานข้อมูล Mongo
  • +500ms: ไคลเอนต์รับค่าการเปลี่ยนแปลง แล้วยกเลิกการเปลี่ยนแปลงที่จำลองขึ้น และใช้การเปลี่ยนแปลงจากเซิร์ฟเวอร์แทน (ซึ่งโดยทั่วไปจะเหมือนกัน) จากนั้นอัพเดทหน้าจอตามการเปลี่ยนแปลง

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

สังเกตุการชดเชยความล่าช้า

เราก็แค่เปลี่ยนอะไรเล็กน้อยกับเมธอด post เพื่อสังเกตุการทำงานนี้ เริ่มจากการใช้ฟังก์ชัน Meteor._sleepForMs() เพื่อหน่วงการทำงานของเมธอดประมาณ 5 วินาที (ตรงนี้สำคัญมาก) บนเซิร์ฟเวอร์

เราจะใช้ isServer เพื่อถาม Meteor ว่าตอนนี้กำลังทำงานอยู่ที่ไคลเอนต์ (ที่เรียกว่า stub) หรือบนเซิร์ฟเวอร์ ซึ่ง stub ก็คือ เมธอดจำลองการทำงานที่ Meteor รันบนไคลเอนต์ไปพร้อมๆกัน ในขณะที่เมธอด “จริง” จะรันอยู่บนเซิร์ฟเวอร์

จากนั้น เราจะบอก Meteor ว่า ถ้าโค้ดที่กำลังรันนั้นเกิดบนเซิร์ฟเวอร์ ให้หน่วงเวลาไว้ซัก 5 วินาที และเพิ่มคำว่า (server) ไปที่ตอนท้ายของชื่อข่าว ถ้าไม่ใช่ให้เพิ่มคำว่า (client) เข้าไปแทน

Posts = new Mongo.Collection('posts');

Meteor.methods({
  postInsert: function(postAttributes) {
    check(this.userId, String);
    check(postAttributes, {
      title: String,
      url: String
    });

    if (Meteor.isServer) {
      postAttributes.title += "(server)";
      // wait for 5 seconds
      Meteor._sleepForMs(5000);
    } else {
      postAttributes.title += "(client)";
    }

    var postWithSameLink = Posts.findOne({url: postAttributes.url});
    if (postWithSameLink) {
      return {
        postExists: true,
        _id: postWithSameLink._id
      }
    }

    var user = Meteor.user();
    var post = _.extend(postAttributes, {
      userId: user._id, 
      author: user.username, 
      submitted: new Date()
    });

    var postId = Posts.insert(post);

    return {
      _id: postId
    };
  }
});
collections/posts.js

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

เพื่อให้เข้าใจว่าทำไม เราจะกลับไปที่โค้ดของตัวจัดการเหตุการณ์ submit

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    };

    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return alert(error.reason);

      // show this result but route anyway
      if (result.postExists)
        alert('This link has already been posted');

      Router.go('postPage', {_id: result._id});  
    });
  }
});
client/templates/posts/post_submit.js

ที่เราวางคำสั่ง Router.go() ไว้ในฟังก์ชัน callback ก็เพื่อต้องการให้ฟอร์มหยุดรอจนกว่าเมธอดจะทำงานเสร็จ จากนั้นจึงค่อยเปลี่ยนหน้าเว็บให้เรา

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

แต่สำหรับตัวอย่างที่เรากำลังดูอยู่นี้ เราต้องการเห็นผลลัพธ์ของการทำงานทันที ดังนั้นเราจึงเปลี่ยนเส้นทางไปที่ postsList แทน (เราไม่สามารถเปลี่ยนเส้นทางไปที่หน้าข่าวได้ เพราะไม่รู้ค่า _id จากข้างนอกเมธอด) โดยเอาคำสั่งเปลี่ยนเส้นทางออกจากฟังก์ชัน callback เมื่อเรียบร้อยก็จะได้แบบนี้

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    };

    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return alert(error.reason);

      // show this result but route anyway
      if (result.postExists)
        alert('This link has already been posted');
    });

    Router.go('postsList');  

  }
});
client/templates/posts/post_submit.js

คอมมิท 7-5-1

Demonstrate the order that posts appear using a sleep.

ถ้าเราสร้างข่าวใหม่ตอนนี้ เราจะเห็นการชดเชยความล่าช้าเกิดขึ้นชัดเจน เริ่มแรก ชื่อข่าวถูกต่อท้ายด้วย (client) (ข่าวแรกในรายการ ลิงก์ไปที่ GitHub)

Our post as first stored in the client collection
Our post as first stored in the client collection

จากนั้นอีก 5 วินาทีต่อมา มันก็ถูกแทนที่ด้วยเอกสารจริงซึ่งได้จากเซิร์ฟเวอร์

Our post once the client receives the update from the server collection
Our post once the client receives the update from the server collection

เมธอดกับคอลเลคชั่นที่ไคลเอนต์

จากที่ผ่านมาคุณอาจคิดว่า เมธอดนั้นค่อนข้างซับซ้อน แต่ความเป็นจริงก็คือ มันทำงานแบบง่ายๆก็ได้ และเราก็เห็นเมธอดแบบง่ายๆกันมาแล้วสามตัวที่เกียวข้องกับการจัดการคอลเลคชั่น ได้แก่ insert, update และ remove

โดยเมื่อคุณสร้างคอลเลคชั่นบนเซิร์ฟเวอร์ชื่อ 'posts' คุณก็ได้สร้างเมธอดขึ้นมาสามตัวโดยไม่รู้ตัว คือ posts/insert, posts/update และ posts/delete หรืออีกนัยนึงก็คือ เมื่อคุณเรียกใช้ Posts.insert() กับคอลเลคชั่นของคุณที่ไคลเอนต์ คุณก็กำลังเรียกใช้เมธอดที่ชดเชยความล่าช้า ซึ่งทำงานสองอย่างนี้

  1. ตรวจดูว่า เรามีสิทธิที่จะเปลี่ยนแปลงข้อมูลได้หรือไม่ โดยเรียกใช้ฟังก์ชัน callback ทั้ง allow และ deny (อย่างไรก็ดี สิ่งนี้ไม่จำเป็นต้องเกิดขึ้นที่ฝั่งไคลเอนต์ ในตอนที่จำลองการทำงาน)
  2. ทำให้เกิดการเปลี่ยนแปลงจริงๆกับแหล่งเก็บข้อมูล

เมธอดเรียกใช้เมธอด

ถ้าคุณตามทัน คุณอาจเพิ่งเข้าใจว่า เมธอด post ก็เรียกใช้อีกเมธอด (post/insert) เมื่อเราเพิ่มข่าวเข้าไป แต่อาจสงสัยอยู่ว่ามันทำงานได้ยังไง

เมื่อการทำงานแบบจำลองเกิดขึ้น (ที่เมธอดฝั่งไคลเอนต์) เราก็เรียกใช้ insert แบบจำลอง (เพิ่มข้อมูลเข้าไปที่คอลเลคชั่นฝั่งไคลเอนต์) แต่เราไม่ได้เรียกใช้ insert จริงๆบนฝั่งเซิร์ฟเวอร์ เพราะเรารู้ว่าเมธอด post เวอร์ชันบนเซิร์ฟเวอร์จะทำตรงนี้ให้

ผลก็คือ เมื่อเมธอด post บนฝั่งเซิร์ฟเวอร์เรียกใช้ insert มันก็ไม่ต้องกังวลกับการทำงานแบบจำลองนั้น และทำงานไปแบบที่เคยเป็น

และก็เหมือนกับบทแทรกก่อนๆ คุณต้องไม่ลืมที่จะเปลี่ยนโค้ดกลับมาเหมือนเดิม ก่อนที่จะอ่านบทต่อไป

แก้ไขข่าว

8

ตอนนี้เราก็สร้างข่าวใหม่ได้แล้ว ขั้นตอนต่อไปคือ ทำให้สามารถแก้ไขและลบข่าวได้ เนื่องจากในส่วนของหน้าจอนั้นค่อนข้างง่าย บทนี้เราก็จะอธิบายเพิ่มเติมเรื่องการจัดการสิทธิการใช้งานต่างๆของผู้ใช้ด้วย Meteor

เริ่มแรกก็ผูกข้อมูลที่ตัวจัดการเส้นทางก่อน โดยเราจะเพิ่มเส้นทางไปที่หน้าแก้ไขข่าว และกำหนดชุดข้อมูลให้มัน

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});

Router.route('/posts/:_id', {
  name: 'postPage',
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/posts/:_id/edit', {
  name: 'postEdit',
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/submit', {name: 'postSubmit'});

var requireLogin = function() {
  if (! Meteor.user()) {
    if (Meteor.loggingIn()) {
      this.render(this.loadingTemplate);
    } else {
      this.render('accessDenied');
    }
  } else {
    this.next();
  }
}

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
lib/router.js

เทมเพลตหน้าแก้ไขข่าว

ตอนนี้เราก็มาดูที่เทมเพลต ซึ่งเทมเพลต postEdit ของเราก็เป็นฟอร์มแบบที่ใช้กันทั่วไป

<template name="postEdit">
  <form class="main form page">
    <div class="form-group">
      <label class="control-label" for="url">URL</label>
      <div class="controls">
          <input name="url" id="url" type="text" value="{{url}}" placeholder="Your URL" class="form-control"/>
      </div>
    </div>
    <div class="form-group">
      <label class="control-label" for="title">Title</label>
      <div class="controls">
          <input name="title" id="title" type="text" value="{{title}}" placeholder="Name your post" class="form-control"/>
      </div>
    </div>
    <input type="submit" value="Submit" class="btn btn-primary submit"/>
    <hr/>
    <a class="btn btn-danger delete" href="#">Delete post</a>
  </form>
</template>
client/templates/posts/post_edit.html

แล้วก็ไฟล์ post_edit.js ที่ต้องใช้คู่กัน

Template.postEdit.events({
  'submit form': function(e) {
    e.preventDefault();

    var currentPostId = this._id;

    var postProperties = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    }

    Posts.update(currentPostId, {$set: postProperties}, function(error) {
      if (error) {
        // display the error to the user
        alert(error.reason);
      } else {
        Router.go('postPage', {_id: currentPostId});
      }
    });
  },

  'click .delete': function(e) {
    e.preventDefault();

    if (confirm("Delete this post?")) {
      var currentPostId = this._id;
      Posts.remove(currentPostId);
      Router.go('postsList');
    }
  }
});
client/templates/posts/post_edit.js

ถึงตรงนี้ คุณก็คงคุ้นเคยกับโค้ดเกือบทั้งหมดแล้ว

เรามีฟังก์ชัน callback สองตัว ตัวแรกใช้กับเหตุการณ์ submit ของฟอร์ม และอีกตัวใช้เหตุการณ์ click ของลิงก์ delete

ฟังก์ชัน callback ของการ delete นั้นง่ายมากๆ แค่ปิดการทำงานของการคลิ๊ก และยืนยันการทำงานกับผู้ใช้ ถ้าตอบตกลง ก็จะนำค่า ID ของข่าวปัจจุบันจากชุดข้อมูลของเทมเพลต มาใช้ลบข่าวนั้น และสุดท้ายก็ส่งผู้ใช้กลับไปที่หน้าโฮม

ส่วน callback ของการอัพเดทนั้นยาวกว่านิดนึง แต่ก็ไม่ได้ซับซ้อนมากกว่าเดิมเท่าไหร่ หลังจากที่ปิดการทำงานเดิมของฟอร์ม และได้ค่า ID ของข่าวปัจจุบันมาแล้ว ก็จะดึงค่าฟิลด์จากหน้าเพจไปเก็บไว้ในอ็อบเจกต์ postProperties

จากนั้นเราก็ส่งอ็อบเจกต์นี้ไปให้เมธอด Collection.update() ของ Meteor ด้วยตัวดำเนินการ $set (ที่จะเปลี่ยนเฉพาะค่าของฟิลด์ที่ระบุ โดยไม่ยุ่งกับฟิลด์อื่น) และใช้ callback เพื่อแสดงข้อความผิดพลาด หรือส่งผู้ใช้กลับไปที่หน้าข่าว ถ้าทำการอัพเดทได้สำเร็จ

เพิ่มลิงก์การแก้ไข

เพื่อให้ผู้ใช้รู้ว่าสามารถเข้าไปแก้ไขข่าวได้ เราก็ควรเพิ่มลิงก์แก้ไขเข้าไปในหน้าข่าวด้วย

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
      <p>
        submitted by {{author}}
        {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
      </p>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn btn-default">Discuss</a>
  </div>
</template>
client/templates/posts/post_item.html

และแน่นอนว่าเราไม่ต้องการให้คุณเห็นลิงก์แก้ไขที่หน้าข่าวของคนอื่น เราก็เลยต้องใช้ตัวช่วย ownPost กับงานนี้

Template.postItem.helpers({
  ownPost: function() {
    return this.userId === Meteor.userId();
  },
  domain: function() {
    var a = document.createElement('a');
    a.href = this.url;
    return a.hostname;
  }
});
client/templates/posts/post_item.js
Post edit form.
Post edit form.

หน้าฟอร์มแก้ไขข่าวของเราดูดีทีเดียว แต่ทว่าคุณก็ยังไม่สามารถแก้ไขอะไรได้จริงๆในตอนนี้ มันเกิดอะไรขึ้นกันแน่

กำหนดสิทธิการใช้งาน

ตั้งแต่ตอนที่เราถอนแพ็คเกจ insecure ออกไป การแก้ไขที่ไคลเอนต์ก็จะถูกปฏิเสธทั้งหมด

วิธีแก้ไขเรื่องนี้ เราจะกำหนดสิทธิการใช้งานบางอย่างขึ้นมา โดยเริ่มจากสร้างไฟล์ permissions.js ใน lib เพื่อให้แน่ใจว่า โค้ดของการกำหนดสิทธิถูกโหลดไว้ตั้งแต่แรก (และใช้ได้กับทั้งสองฝั่ง)

// check that the userId specified owns the documents
ownsDocument = function(userId, doc) {
  return doc && doc.userId === userId;
}
lib/permissions.js

ในบท สร้างข่าวใหม่ เราได้ลบเมธอด allow() ออกไป เพราะตอนนั้นเราใช้วิธีเพิ่มข่าวใหม่ด้วยเมธอดที่ฝั่งเซิร์ฟเวอร์ (ซึ่งไม่สนใจโค้ดของ allow() อยู่แล้ว)

แต่ตอนนี้เรากำลังจะแก้ไขและลบข่าวด้วยโค้ดที่ฝั่งไคลเอนต์ ดังนั้นให้กลับไปที่ posts.js และเพิ่มบล็อก allow() เข้าไปใหม่

Posts = new Mongo.Collection('posts');

Posts.allow({
  update: function(userId, post) { return ownsDocument(userId, post); },
  remove: function(userId, post) { return ownsDocument(userId, post); },
});

//...
lib/collections/posts.js

คอมมิท 8-2

Added basic permission to check the post’s owner.

จำกัดการแก้ไข

เพียงเพราะเราสามารถแก้ไขข่าวของตัวเองได้ ไม่ได้หมายความว่า เราต้องแก้ไข ทุกๆ ฟิลด์ ตัวอย่างเช่น เราไม่ต้องการให้ผู้ใช้สร้างข่าว แล้วส่งต่อมันให้เป็นของคนอื่น

ดังนั้นเราจะใช้ฟังก์ชัน callback deny() ของ Meteor เพื่อจำกัดฟิลด์ให้เหลือเฉพาะเท่าที่ผู้ใช้สามารถแก้ไขได้

Posts = new Mongo.Collection('posts');

Posts.allow({
  update: function(userId, post) { return ownsDocument(userId, post); },
  remove: function(userId, post) { return ownsDocument(userId, post); },
});

Posts.deny({
  update: function(userId, post, fieldNames) {
    // may only edit the following two fields:
    return (_.without(fieldNames, 'url', 'title').length > 0);
  }
});

//...
lib/collections/posts.js

คอมมิท 8-3

Only allow changing certain fields of posts.

เรานำอาร์เรย์ fieldNames ที่ประกอบด้วยชื่อฟิลด์ซึ่งกำลังถูกแก้ไข มาใช้กับเมธอด without() ของ Underscore เพื่อหาค่าอาร์เรย์ของชื่อฟิลด์ที่ ไม่ใช่ url หรือ title

ถ้าทุกอย่างเป็นปกติ อาร์เรย์ตัวนั้นควรจะว่างและขนาดของมันควรเป็น 0 แต่ถ้ามีใครบางคนลองอะไรแปลกๆ ขนาดของอาร์เรย์อาจจะเป็น 1 หรือมากกว่า และทำให้ callback คืนค่า true (เท่ากับปฏิเสธการอัพเดท)

คุณอาจจะสังเกตุว่า ไม่มีตรงไหนในโค้ดของการแก้ไขข่าว ที่เราเช็คว่าลิงก์นั้นซ้ำหรือไม่ ซึ่งก็หมายความว่า ผู้ใช้สามารถป้อนลิงก์เข้ามาและทำการแก้ไข URL ของมันทีหลัง เพื่อเลี่ยงการตรวจสอบในตอนแรกได้ ซึ่งการแก้ไขในเรื่องนี้สามารถทำได้โดยใช้เมธอดของ Meteor กับหน้าฟอร์มแก้ไข แต่เราจะทิ้งเรื่องนี้ไว้เป็นแบบฝึกหัดให้ผู้อ่านแทน

เปรียบเทียบระหว่างการใช้เมธอด กับ การจัดการข้อมูลที่ไคลเอนต์

ในการสร้างข่าวนั้น เราเรียกใช้เมธอด postInsert ของ Meteor แต่ในขณะที่การแก้ไขและลบข่าว เราเรียกใช้ update และ remove โดยตรงที่ไคลเอนต์ โดยกำหนดสิทธิไว้ที่ allow และ deny

แล้วเมื่อไรที่เราจะเลือกใช้อย่างนึง และไม่ใช้อีกอย่าง

ถ้าสิ่งนั้นไม่ซับซ้อนอะไร และคุณสามารถกำหนดกฏเกณฑ์ง่ายๆที่จะ allow และ deny ได้ มันก็จะง่ายกว่าถ้าจะจัดการสิ่งนั้นที่ไคลเอนต์โดยตรง

แต่เมื่อไรก็ตาม ที่คุณจำเป็นต้องจัดการบางสิ่งนอกเหนือการควบคุมของผู้ใช้ (เช่น บันทึกเวลาของข่าวใหม่ หรือกำหนดชื่อผู้ใช้ที่ถูกต้องให้กับข่าว) มันก็น่าจะดีกว่าถ้าจะใช้เมธอด

การเรียกใช้เมธอด จะเหมาะสมกว่ากับสถานการณ์ต่อไปนี้

  • เมื่อคุณจำเป็นต้องรู้ค่า หรือต้องคืนค่าผ่านฟังก์ชัน callback มากกว่าที่จะคอยการทำงานแบบรีแอคทีฟและการซิงโครไนซ์ให้เกิดขึ้น
  • ในฟังก์ชันที่ใช้งานฐานข้อมูลมากๆ จะเป็นการสิ้นเปลืองถ้าจะส่งคอลเลคชั่นขนาดใหญ่ข้ามไปข้ามมา
  • ในการสรุปและรวบรวมข้อมูล (เช่น การนับ, ค่าเฉลี่ย, ผลรวม)

ถ้าต้องการคำอธิบายที่ละเอียดกว่านี้ คุณสามารถเข้าไปดูเพิ่มเติมได้จากในบล็อกของเรา

การ Allow และ Deny

Sidebar 8.5

ระบบรักษาความปลอดภัยของ Meteor นั้น ยอมให้เราทำการเปลี่ยนแปลงกับฐานข้อมูลโดยไม่จำเป็นต้องสร้างเมธอดขึ้นมาใหม่ทุกครั้ง ในขณะที่เราทำการเปลี่ยนแปลงนั้น

เนื่องจากเราอาจจำเป็นต้องทำงานบางอย่างเพิ่มเติม เช่น ปรับแต่งข้อมูลข่าวด้วยฟิลด์พิเศษ และดำเนินการบางอย่างเมื่อ URL ของข่าวซ้ำกับที่มีอยู่แล้ว การใช้เมธอด post ในขณะที่กำลังสร้างข่าวก็ดูเหมาะสมดี

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

การใช้ฟังก์ชัน callback เหล่านี้ทำให้เราสามารถระบุวิธีการเปลี่ยนแปลงฐานข้อมูลได้ชัดเจนขึ้น โดยแค่กำหนดว่าการอัพเดทแบบไหนที่สามารถทำงานได้ก็พอ ซึ่งความสามารถทั้งหมดที่ว่ามานี้เราได้มาฟรีจากระบบบัญชีผู้ใช้อยู่แล้ว

ฟังก์ชัน callback หลายตัว

เราสามารถกำหนดฟังก์ชัน callback แบบ allow กี่ตัวก็ได้เท่าที่จำเป็น ขอแค่ให้มี ตัวใดตัวหนึ่ง คืนค่าเป็น true การเปลี่ยนแปลงที่รออยู่ก็จะเกิดขึ้นได้ โดยเมื่อ Posts.insert ถูกเรียกใช้จากเบราว์เซอร์ (ไม่ว่าจากในแอพหรือจากคอนโซล) เซิร์ฟเวอร์จะทำการตรวจค่า insert จากฟังก์ชัน allow ทั้งหมดจนกว่าจะพบตัวนึงที่มีค่าเป็นจริง และถ้าหาไม่พบเลย ก็จะไม่ยอมให้มีการทำงานเกิดขึ้น ทั้งยังคืนค่าผิดพลาด 403 กลับมาที่ไคลเอนต์ด้วย

ในทำนองเดียวกัน เราก็สามารถกำหนดฟังก์ชัน callback แบบ deny ได้หลายตัวเช่นกัน ซึ่งถ้ามี ตัวใดตัวนึง คืนค่าเป็น true การเปลี่ยนแปลงก็จะถูกยกเลิก และค่าความผิดพลาด 403 ก็จะถูกส่งกลับมา ตรรกะแบบนี้หมายความได้ว่า การ insert ที่สมบูรณ์จะเกิดขึ้นได้ก็ต่อเมื่อฟังก์ชัน callback แบบ allow insert อย่างน้อยหนึ่งตัว กับฟังก์ชัน callback แบบ deny insert ทุกๆตัวถูกตรวจสอบแล้ว

Note: n/e stands for Not Executed
Note: n/e stands for Not Executed

มองได้อีกแบบคือ Meteor จะทำการตรวจสอบฟังก์ชัน callback โดยเริ่มต้นจากแบบ deny แล้วจึงมาที่ allow และตรวจทีละตัวจนกว่าจะพบว่าตัวใดตัวนึงคืนค่าเป็น true

ตัวอย่างที่พบเสมอคือ มีการกำหนดฟังก์ชัน callback แบบ allow() ไว้สองตัว ตัวแรกคอยตรวจว่าข่าวนี้เป็นของผู้ใช้ปัจจุบันหรือไม่ ตัวที่สองตรวจว่าผู้ใช้ปัจจุบันเป็นผู้ดูแล (admin) หรือไม่ ในกรณีที่ผู้ใช้ปัจจุบันเป็นผู้ดูแล ก็มั่นใจได้เลยว่าเค้าจะสามารถอัพเดทข่าวไหนก็ได้ เพราะว่าค่าที่ได้คืนจากฟังก์ชัน callback อย่างน้อยต้องเป็นจริงแน่ๆ

การชดเชยความล่าช้า

ให้จำไว้เลยว่า เมธอดที่เปลี่ยนแปลงฐานข้อมูล (เช่น .update()) จะทำงานแบบชดเชยความล่าช้าเหมือนกับเมธอดอื่นๆด้วย อย่างเช่น ถ้าคุณพยายามลบข่าวที่ไม่ใช่ของคุณผ่านคอนโซลของเบราว์เซอร์ คุณจะเห็นว่าข่าวที่ลบจะหายไปแว้บนึงเพราะคอลเลคชั่นที่ไคลเอนต์ถูกลบข้อมูลไป แต่แล้วข่าวนั้นก็จะแสดงขึ้นมาใหม่อีกครั้ง เพราะเซิร์ฟเวอร์แจ้งมาว่าข่าวนั้นยังไม่ได้ถูกลบออกไป

จริงอยู่ที่การทำงานลักษณะนี้ไม่ใช่ปัญหาเมื่อคุณสั่งงานผ่านคอนโซล (ซึ่งจะว่าไป ถ้าผู้ใช้จะลองทำอะไรกับข้อมูลผ่านทางคอนโซล อะไรที่เกิดในเบราว์เซอร์ของ พวกเค้า ก็ไม่ใช่ปัญหาของคุณ) แต่อย่างไรก็ตาม คุณต้องแน่ใจว่าสิ่งนี้จะไม่เกิดกับหน้าจอการใช้งานของคุณ อย่างเช่น คุณจำเป็นต้องหาวิธีทำให้ปุ่มลบข่าว ไม่ปรากฏบนข่าวที่พวกเค้าไม่มีสิทธิลบ

ทั้งนี้ต้องขอบคุณ ความสามารถของการแชร์โค้ดให้ใช้งานกันได้ระหว่างไคลเอนต์และเซิร์ฟเวอร์ (อย่างเช่น คุณสามารถเขียนฟังก์ชัน canDeletePost(user, post) และวางมันไว้ในโฟลเดอร์ /lib ที่แชร์ระหว่างสองฝั่ง) ช่วยให้คุณไม่ต้องเขียนโค้ดมากเกินความจำเป็น

สิทธิการใช้งานบนฝั่งเซิร์ฟเวอร์

ที่ต้องจำอีกเรื่องคือ สิทธิการใช้งานของระบบจะถูกใช้เมื่อการเปลี่ยนแปลงฐานข้อมูลถูกสั่งจากฝั่งไคลเอนต์เท่านั้น ส่วนบนเซิร์ฟเวอร์ Meteor จะสมมุติว่า การทำงานทุกอย่างได้รับการอนุญาตแล้ว

นั่นหมายความว่า ถ้าคุณจะเขียนเมธอดของ Meteor ชื่อ deletePost ให้ทำงานบนฝั่งเซิร์ฟเวอร์ และให้ถูกเรียกใช้ได้จากฝั่งไคลเอนต์ ผลก็คือ ไม่ว่าใครก็สามารถลบข่าวไหนก็ได้ ซึ่งคุณคงไม่คิดที่จะทำอย่างนั้นถ้าคุณยังไม่ได้ใส่โค้ดตรวจสอบสิทธิของผู้ใช้ลงในเมธอดนั้นแล้ว

รับมือกับความผิดพลาด

9

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

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

ซึ่งสิ่งที่เราจะสร้างนี้ก็คือ ระบบง่ายๆใช้แสดงข้อผิดพลาด ที่ด้านมุมขวาบนของหน้าจอ คล้ายๆกับแอพของ Mac ที่ชื่อ Growl

รู้จักกับคอลเลกชั่นแบบโลคอล (Local Collections)

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

เพื่อให้เป็นไปตามนี้ เราก็จะสร้างคอลเลกชั่นของข้อผิดพลาดไว้ในโฟลเดอร์ client (เพื่อให้เรียกใช้จากฝั่งไคลเอนต์เท่านั้น) โดยระบุชื่อของคอลเลกชั่น MongoDB เป็น null (เนื่องจากเราจะไม่เก็บข้อมูลของคอลเลกชั่นนี้ลงฐานข้อมูลที่เซิร์ฟเวอร์เลย)

// Local (client-only) collection
Errors = new Mongo.Collection(null);
client/helpers/errors.js

เมื่อมีคอลเลกชั่นแล้ว เราก็สร้างฟังก์ชั่น throwError เพื่อใส่ข้อผิดพลาดเข้าไปในนั้น โดยเราไม่ต้องกังวลเรื่อง allow หรือ deny หรือความปลอดภัยอื่นๆ เนื่องจากคอลเลกชั่นนี้เป็นของผู้ใช้คนปัจจุบันเท่านั้น

throwError = function(message) {
  Errors.insert({message: message})
}
client/helpers/errors.js

ข้อดีของการนำคอลเลกชั่นแบบโลคอลมาใช้เก็บข้อผิดพลาด ที่เหมือนกับคอลเลกชั่นแบบอื่่นๆ ก็คือ ความเป็นรีแอคทีฟ ซึ่งหมายความว่า เราสามารถแสดงข้อความผิดพลาดได้ในแบบรีแอคทีฟเหมือนๆกับที่เราแสดงข้อมูลจากคอลเลกชั่นอื่นๆนั่นเอง

แสดงข้อผิดพลาด

เราจะแทรกข้อผิดพลาดไว้ที่ส่วนบนของไฟล์เลย์เอาท์ของเรา ดังนี้

<template name="layout">
  <div class="container">
    {{> header}}
    {{> errors}}
    <div id="main">
      {{> yield}}
    </div>
  </div>
</template>
client/templates/application/layout.html

และสร้างเทมเพลท errors และ error ในไฟล์ errors.html

<template name="errors">
  <div class="errors">
    {{#each errors}}
      {{> error}}
    {{/each}}
  </div>
</template>

<template name="error">
  <div class="alert alert-danger" role="alert">
    <button type="button" class="close" data-dismiss="alert">&times;</button>
    {{message}}
  </div>
</template>
client/templates/includes/errors.html

เทมเพลทคู่

คุณอาจสังเกตุเห็นว่าเราได้ใส่เทมเพลทสองตัวในไฟล์เดียวกัน ที่ผ่านมาเราใช้แบบ “หนึ่งไฟล์ หนึ่งเทมเพลท” สำหรับ Meteor แล้ว การที่เราเอาเทมเพลททั้งหมดมารวมไว้ในไฟล์เดียวกันไม่ทำให้เกิดปัญหาอะไร (แต่มันอาจทำให้เราสับสนได้ ถ้าเอามารวมไว้ที่ main.html ไฟล์เดียว)

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

ตอนนี้เราก็เหลือแค่สร้างตัวช่วยเทมเพลท จากนั้นเราก็พร้อมจะไปกันต่อ!

Template.errors.helpers({
  errors: function() {
    return Errors.find();
  }
});
client/templates/includes/errors.js

ถึงตรงนี้ คุณก็พร้อมที่จะทดสอบการแสดงข้อผิดพลาดนี้ด้วยตัวเองแล้ว แค่เปิดคอนโซลของเบราว์เซอร์และพิมพ์

throwError("I'm an error!");
Testing error messages.
Testing error messages.

ข้อผิดพลาดสองรูปแบบ

เป็นเรื่องสำคัญที่เราต้องแยกแยะความแตกต่างระหว่างข้อผิดพลาดในระดับแอพ app-level และในระดับโค้ด code-level

ข้อผิดพลาดในระดับแอพ โดยทั่วไปเกิดจากการทำงานของผู้ใช้ และผู้ใช้งานก็สามารถจัดการพวกมันได้ ที่เห็นได้ชัดคือ ข้อผิดพลาดจากการตรวจสอบ ข้อผิดพลาดจากสิทธิการใช้งาน ข้อผิดพลาดจาก not-found และอื่นๆ ซึ่งข้อผิดพลาดเหล่านี้เป็นสิ่งที่เราต้องแสดงต่อผู้ใช้ เพื่อช่วยให้พวกเค้าแก้ไขปัญหาที่กำลังเกิดขึ้นได้

ข้อผิดพลาดในระดับโค้ดนั้น เป็นอีกเรื่องนึง ส่วนใหญ่เกิดมาจากบั๊กในโค้ดที่คุณเขียน คาดเดาไม่ได้ และคุณก็ ไม่อยาก จะแสดงให้ผู้ใช้เห็นโดยตรง แต่อาจจะแค่ต้องการติดตามมันด้วยบริการติดตามข้อผิดพลาดที่เปิดให้บริการอยู่ก็พอ (เช่นที่ Kadira )

โดยในบทนี้เราจะเน้นที่ข้อผิดพลาดในระดับแอพเท่านั้น ไม่ใช่การไล่หาบั๊กแต่อย่างใด

สร้างข้อผิดพลาด

ตอนนี้เราก็รู้วิธีแสดงข้อผิดพลาดแล้ว แต่ก่อนที่เราจะเห็นมันเราก็ต้องทำให้มันเกิดขึ้นซะก่อน ซึ่งที่ผ่านมาเราได้เตรียมโค้ดรองรับเมื่อมีข้อผิดพลาดไว้ดีอยู่แล้ว เช่น การเตือนเมื่อข่าวที่โพสท์ซ้ำกัน ตอนนี้เราก็แค่เปลี่ยนฟังก์ชัน alert ในตัวช่วยเหตุการณ์ postSubmit ให้เป็นฟังก์ชัน throwError ดังนี้

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    };

    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return throwError(error.reason);

      // show this result but route anyway
      if (result.postExists)
        throwError('This link has already been posted');

      Router.go('postPage', {_id: result._id});  
    });
  }
});
client/templates/posts/post_submit.js

จากนั้น เราก็จะทำเหมือนกันที่ตัวช่วยเหตุการณ์ postEdit

Template.postEdit.events({
  'submit form': function(e) {
    e.preventDefault();

    var currentPostId = this._id;

    var postProperties = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    }

    Posts.update(currentPostId, {$set: postProperties}, function(error) {
      if (error) {
        // display the error to the user
        throwError(error.reason);
      } else {
        Router.go('postPage', {_id: currentPostId});
      }
    });
  },
  //...
});
client/templates/posts/post_edit.js

คอมมิท 9-2

Actually use the error reporting.

มาทดสอบกันดูหน่อย ลองสร้างข่าวใหม่โดยป้อน URL เป็น http://meteor.com ให้ซ้ำกับข่าวเดิมที่สร้างไว้แล้ว คุณก็จะเห็นอะไรแบบนี้

Triggering an error
Triggering an error

ลบข้อผิดพลาด

คุณน่าจะสังเกตุเห็นว่า ข้อความผิดพลาดต่างๆจะเลือนหายไปเองในเวลาไม่กี่วินาที ทั้งนี้ก็เพราะความมหัศจรรย์ของ CSS ที่เราใส่ไว้ในสไตล์ชีตเมื่อตอนเริ่มต้นของหนังสือเล่มนี้

@keyframes fadeOut {
  0% {opacity: 0;}
  10% {opacity: 1;}
  90% {opacity: 1;}
  100% {opacity: 0;}
}

//...

.alert {
  animation: fadeOut 2700ms ease-in 0s 1 forwards;
  //...
}
client/stylesheets/style.css

ที่เราทำคือ สร้างอนิเมชั่น fadeOut ใน CSS ให้มี 4 คีย์เฟรม โดยกำหนดค่าความทึบแสงให้แตกต่างกัน (ณ ตำแหน่ง 0%, 10%, 90%, และ 100% ของการเกิดอนิเมชั่น) และใช้อนิเมชั่นนี้กับคลาส .alert

โดยอนิเมชั่นนี้จะใช้เวลาทั้งหมด 2700 มิลลิวินาที ด้วยค่าที่กำหนดคือ ใช้สูตรเวลาแบบ ease-in , รันแบบหน่วง 0 วินาที , รันหนึ่งครั้ง และให้แสดงที่คีย์เฟรมสุดท้ายหลังจากรันจบ

อนิเมชั่นแบบไหนดี

คุณอาจกำลังสงสัยว่าทำไมเราใช้อนิเมชั่นแบบ CSS (ซึ่งถูกกำหนดไว้ล่วงหน้าและอยู่นอกเหนือการควบคุมของแอพ) แทนที่จะใช้อนิเมชั่นที่ควบคุมจาก Meteor เอง

ถึงแม้ Meteor จะรองรับการสร้างอนิเมชั่นได้หลากหลาย แต่เนื่องจากเราต้องการให้บทนี้เน้นที่ข้อผิดพลาด เราจึงเลือกที่จะใช้อนิเมชั่นแบบง่ายๆของ CSS และเก็บสิ่งที่น่าสนใจไว้ในบทอนิเมชั่นโดยเฉพาะ

ดูเหมือนว่าจะใช้การได้แล้ว แต่ถ้าคุณลองทำให้เกิดข้อผิดพลาดหลายๆครั้ง (เช่น ป้อนค่าลิงก์ที่เหมือนๆกันซักสามครั้ง) คุณก็จะเห็นข้อผิดพลาดนั้นเลื่อนตำแหน่งลงมาเรื่อยๆ

Stack overflow.
Stack overflow.

ที่เป็นแบบนี้ก็เพราะในขณะที่ตัว .alert ดูเลือนหายไป แต่มันยังคงอยู่ใน DOM ไม่ได้หายไปไหน ซึ่งเป็นเรื่องที่เราต้องแก้ไข

สถานะการณ์แบบนี้จะเข้าทาง Meteor พอดี เนื่องจากคอลเลกชั่นข้อผิดพลาดเป็นแหล่งข้อมูลแบบรีแอคทีฟ สิ่งที่เราต้องทำเพื่อกำจัดข้อผิดพลาดเก่าก็แค่ลบมันออกจากคอลเลกชั่นเท่านั้น!

โดยเราจะใช้คำสั่ง Meteor.setTimeout เพื่อกำหนดให้ฟังก์ชั่น callback ทำการลบข้อผิดพลาดออกหลังจากหมดเวลาที่ตั้งไว้ (ในกรณีนี้คือ 3000 มิลลิวินาที)

Template.errors.helpers({
  errors: function() {
    return Errors.find();
  }
});

Template.error.onRendered(function() {
  var error = this.data;
  Meteor.setTimeout(function () {
    Errors.remove(error._id);
  }, 3000);
});
client/templates/includes/errors.js

คอมมิท 9-3

Clear errors after 3 seconds.

ฟังก์ชัน callback ของเหตุการณ์ onRendered จะทำงานหลังจากที่เบราว์เซอร์ได้แสดงเทมเพลทแล้ว โดย this ในฟังก์ชัน callback คือ ตัวเทมเพลทที่กำลังใช้งานอยู่ และ this.data ก็คือข้อมูลที่ถูกแสดงนั่นเอง (ในกรณีนี้คือ ข้อผิดพลาด)

ค้นหาความถูกต้อง

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

โดยเราจะทำสองอย่างเพื่อเตือนให้ผู้ใช้รู้ว่ามีข้อมูลตรงไหนที่ขาดหายไป อย่างแรก เราจะใส่ CSS class ที่ div ตัวนอกของฟิลด์ที่มีปัญหา และอย่างที่สอง เราจะแสดงข้อความผิดพลาดที่มีประโยชน์ข้างใต้ฟิลด์นั้น

เราเริ่มด้วยการเตรียมเทมเพลท postSubmit ให้รองรับตัวช่วยใหม่ตามนี้

<template name="postSubmit">
  <form class="main form page">
    <div class="form-group {{errorClass 'url'}}">
      <label class="control-label" for="url">URL</label>
      <div class="controls">
          <input name="url" id="url" type="text" value="" placeholder="Your URL" class="form-control"/>
          <span class="help-block">{{errorMessage 'url'}}</span>
      </div>
    </div>
    <div class="form-group {{errorClass 'title'}}">
      <label class="control-label" for="title">Title</label>
      <div class="controls">
          <input name="title" id="title" type="text" value="" placeholder="Name your post" class="form-control"/>
          <span class="help-block">{{errorMessage 'title'}}</span>
      </div>
    </div>
    <input type="submit" value="Submit" class="btn btn-primary"/>
  </form>
</template>
client/templates/posts/post_submit.html

สังเกตุด้วยว่าเราส่งค่าพารามิเตอร์ (url และ title ตามลำดับ) ไปที่ฟังก์ชั่นตัวช่วยแต่ละตัว โดยเรียกใช้ตัวช่วยตัวเดียวกันซ้ำสองครั้ง แต่เปลี่ยนการทำงานของมันตามค่าพารามิเตอร์ที่ส่งเข้าไป

ได้เวลาสนุกกันแล้ว ตอนนี้เราจะลองเอาตัวช่วยพวกนี้มาใช้ให้เกิดประโยชน์กันดู

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

แรกสุด เราจะกำหนดค่าเริ่มต้นให้อ็อบเจกต์นี้เมื่อเทมเพลท postSubmit ถูกสร้างขึ้น เพื่อให้แน่ใจว่าผู้ใช้จะไม่เห็นข้อความผิดพลาดเดิมจากการใช้งานก่อนหน้านั้น

จากนั้นเราจะสร้างตัวช่วยเทมเพลทขึ้นมาสองตัว ที่คอยตรวจดูค่าคุณสมบัติ field ของ Session.get('postSubmitErrors') (โดยที่ field เป็นได้ทั้ง url หรือ title ขึ้นอยู่กับว่าเราเรียกใช้ตัวช่วยเทมเพลทจากตรงไหน)

โดยตัวช่วย errorMessage จะคืนค่าข้อความผิดพลาดมาให้ แต่ errorClass จะตรวจดูว่า มี ข้อความหรือไม่ และคืนค่า has-error ถ้าพบว่ามีข้อความอยู่

Template.postSubmit.onCreated(function() {
  Session.set('postSubmitErrors', {});
});

Template.postSubmit.helpers({
  errorMessage: function(field) {
    return Session.get('postSubmitErrors')[field];
  },
  errorClass: function (field) {
    return !!Session.get('postSubmitErrors')[field] ? 'has-error' : '';
  }
});

//...
client/templates/posts/post_submit.js

คุณสามารถทดสอบตัวช่วยว่าทำงานถูกต้องหรือไม่ โดยเปิดคอนโซลของเบราว์เซอร์และป้อนโค้ดต่อไปนี้

Session.set('postSubmitErrors', {title: 'Warning! Intruder detected. Now releasing robo-dogs.'});
Browser console
Red alert! Red alert!
Red alert! Red alert!

ขั้นตอนต่อไปคือ ผูกค่าของเซสชั่นอ็อบเจกต์ postSubmitErrors เข้ากับฟอร์ม

ก่อนที่จะทำตรงนั้น ให้เราสร้างฟังก์ชันใหม่ validatePost ใน posts.js เพื่อใช้ตรวจดูอ็อบเจกต์ post และคืนอ็อบเจกต์ errors ที่ประกอบด้วยข้อความผิดพลาดที่เกิดขึ้น (โดยตั้งชื่อคีย์เป็น title หรือ url ตามชื่อฟิลด์ที่ไม่มีข้อมูล )

//...

validatePost = function (post) {
  var errors = {};

  if (!post.title)
    errors.title = "Please fill in a headline";

  if (!post.url)
    errors.url =  "Please fill in a URL";

  return errors;
}

//...
lib/collections/posts.js

ซึ่งเราจะเรียกใช้ฟังก์ชันนี้จากตัวช่วยเหตุการณ์ postSubmit

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    };

    var errors = validatePost(post);
    if (errors.title || errors.url)
      return Session.set('postSubmitErrors', errors);

    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return throwError(error.reason);

      // show this result but route anyway
      if (result.postExists)
        throwError('This link has already been posted');

      Router.go('postPage', {_id: result._id});  
    });
  }
});
client/templates/posts/post_submit.js

ให้สังเกตุด้วยว่า ที่เราใช้ return ก็เพื่อยกเลิกการทำงานของตัวช่วยเมื่อเกิดความผิดพลาดขึ้น แต่ไม่ใช่เพราะเราต้องการคืนค่านี้ออกมา

Caught red-handed.
Caught red-handed.

การตรวจสอบฝั่งเซิร์ฟเวอร์

ดูเหมือนว่าเรายังทำไม่เสร็จซะทีเดียว เราได้ตรวจสอบว่ามีการป้อนข้อมูล URL และชื่อข่าวที่ฝั่ง ไคลเอนต์ แต่บนฝั่ง เซิร์ฟเวอร์ ล่ะ อาจมีใครบางคนพยายามป้อนข่าวแบบว่างๆ ด้วยการเรียกใช้เมธอด postInsert ผ่านคอนโซลของเบราว์เซอร์โดยตรงก็ได้

ถึงแม้ว่าเราไม่จำเป็นต้องแสดงข้อความผิดพลาดบนเซิร์ฟเวอร์ เราก็ยังสามารถใช้ฟังก์ชัน validatePost ตัวเดิมได้ เว้นเสียแต่ว่า ครั้งนี้เราจะเรียกใช้มันจากในเมธอด postInsert ไม่ใช่แค่เรียกใช้จากตัวช่วยเหตุการณ์เท่านั้น

Meteor.methods({
  postInsert: function(postAttributes) {
    check(this.userId, String);
    check(postAttributes, {
      title: String,
      url: String
    });

    var errors = validatePost(postAttributes);
    if (errors.title || errors.url)
      throw new Meteor.Error('invalid-post', "You must set a title and URL for your post");

    var postWithSameLink = Posts.findOne({url: postAttributes.url});
    if (postWithSameLink) {
      return {
        postExists: true,
        _id: postWithSameLink._id
      }
    }

    var user = Meteor.user();
    var post = _.extend(postAttributes, {
      userId: user._id, 
      author: user.username, 
      submitted: new Date()
    });

    var postId = Posts.insert(post);

    return {
      _id: postId
    };
  }
});
lib/collections/posts.js

ย้ำอีกครั้งว่า ผู้ใช้งานผ่านหน้าจอปกติไม่ควรต้องเห็นข้อความ “You must set a title and URL for your post” นี้ เพราะมันจะปรากฎให้เห็นเฉพาะกับผู้ที่ใช้งานโดยไม่ผ่านหน้าจอปกติ แต่ใช้งานผ่านคอนโซลโดยตรงเท่านั้น

ลองทดสอบกันดู โดยเปิดคอนโซลของเบราว์เซอร์ แล้วลองป้อนโพสท์ข่าวที่ไม่มี URL ตามนี้

Meteor.call('postInsert', {url: '', title: 'No URL here!'});

ถ้าเราทำทุกอย่างถูกต้อง คุณจะได้รับโค้ดข้อมูลที่ค่อนข้างเยอะกลับมาพร้อมด้วยข้อความ “You must set a title and URL for your post”

คอมมิท 9-4

Validate post contents on submission.

ตรวจสอบเมื่อทำการแก้ไข

ก่อนจะจบงาน เราจะใช้วิธีการตรวจสอบแบบเดียวกันนี้กับหน้าฟอร์ม edit ของเราเช่นกัน โดยโค้ดที่ได้จะดูคล้ายๆกัน ตัวแรกคือเทมเพลท

<template name="postEdit">
  <form class="main form page">
    <div class="form-group {{errorClass 'url'}}">
      <label class="control-label" for="url">URL</label>
      <div class="controls">
          <input name="url" id="url" type="text" value="{{url}}" placeholder="Your URL" class="form-control"/>
          <span class="help-block">{{errorMessage 'url'}}</span>
      </div>
    </div>
    <div class="form-group {{errorClass 'title'}}">
      <label class="control-label" for="title">Title</label>
      <div class="controls">
          <input name="title" id="title" type="text" value="{{title}}" placeholder="Name your post" class="form-control"/>
          <span class="help-block">{{errorMessage 'title'}}</span>
      </div>
    </div>
    <input type="submit" value="Submit" class="btn btn-primary submit"/>
    <hr/>
    <a class="btn btn-danger delete" href="#">Delete post</a>
  </form>
</template>
client/templates/posts/post_edit.html

ต่อมาก็ตัวช่วยเทมเพลท

Template.postEdit.onCreated(function() {
  Session.set('postEditErrors', {});
});

Template.postEdit.helpers({
  errorMessage: function(field) {
    return Session.get('postEditErrors')[field];
  },
  errorClass: function (field) {
    return !!Session.get('postEditErrors')[field] ? 'has-error' : '';
  }
});

Template.postEdit.events({
  'submit form': function(e) {
    e.preventDefault();

    var currentPostId = this._id;

    var postProperties = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    }

    var errors = validatePost(postProperties);
    if (errors.title || errors.url)
      return Session.set('postEditErrors', errors);

    Posts.update(currentPostId, {$set: postProperties}, function(error) {
      if (error) {
        // display the error to the user
        throwError(error.reason);
      } else {
        Router.go('postPage', {_id: currentPostId});
      }
    });
  },

  'click .delete': function(e) {
    e.preventDefault();

    if (confirm("Delete this post?")) {
      var currentPostId = this._id;
      Posts.remove(currentPostId);
      Router.go('postsList');
    }
  }
});
client/templates/posts/post_edit.js

ก็เหมือนกับที่เราทำกับฟอร์มสร้างข่าว เราต้องตรวจสอบความถูกต้องของข่าวที่เซิร์ฟเวอร์ด้วย ยกเว้นแต่ว่า เราไม่ได้ใช้เมธอดเพื่อแก้ไขข่าว แต่เรียกใช้ update โดยตรงจากไคลเอนต์ ถ้าคุณยังจำได้

นั่นหมายความว่า เราจะต้องเพิ่มฟังก์ชัน callback แบบ deny ตัวใหม่เข้าไปแทน

//...

Posts.deny({
  update: function(userId, post, fieldNames, modifier) {
    var errors = validatePost(modifier.$set);
    return errors.title || errors.url;
  }
});

//...
lib/collections/posts.js

ให้สังเกตุว่าตัวแปร post ที่รับเข้ามาคือ ข่าว เดิม ซึ่งในกรณีนี้เราต้องการตรวจสอบความถูกต้องของการ อัพเดท เราถึงเรียกใช้ validatePost กับค่าคุณสมบัติ $set ของตัว modifier (เหมือนที่ใช้ใน Posts.update({$set: {title: ..., url: ...}}))

ที่ใช้แบบนี้ได้ก็เพราะว่า ใน modifier.$set ประกอบด้วยtitle และ url เหมือนกับที่อ็อบเจกต์ post ทั้งตัวมี และยังหมายความได้อีกว่า การอัพเดทแค่ title หรือ url ตัวใดตัวหนึ่งเพียงตัวเดียว จะไม่สามารถเกิดขึ้นได้แน่นอน ซึ่งในการใช้งานจริงไม่น่ามีปัญหา

คุณอาจสังเกตุเห็นว่า ฟังก์ชัน callback แบบ deny นี้เป็นตัวที่สอง เมื่อเราเพิ่มฟังก์ชัน callback แบบ denyเข้าไปหลายตัว การทำงานจะถูกยกเลิกเมื่อตัวใดตัวหนึ่งมีค่าเป็น true ซึ่งในกรณีนี้หมายความว่า update จะเกิดขึ้นได้กับฟิลด์ title และ url เท่านั้น โดยตัวใดตัวหนึ่งต้องไม่มีค่าว่างด้วย

คอมมิท 9-5

Validate post contents when editing.

การสร้างแพ็คเกจ Meteor

Sidebar 9.5

เราได้สร้างวิธีการจัดการข้อผิดพลาดที่สามารถนำกลับมาใช้ได้ใหม่ ทำไมเราไม่ทำให้เป็นสมาร์ทแพ็คเกจแล้วแบ่งปันให้กับชุมชน Meteor กันล่ะ

ก่อนที่จะเริ่มต้น เราจำเป็นต้องแน่ใจว่าเราได้ลงทะเบียนเป็นนักพัฒนาของ Meteor กันไว้แล้ว โดยคุณสามารถเข้าไปลงทะเบียนได้ที่ meteor.com แต่เราคาดว่าคุณน่าจะทำกันไว้เรียบร้อยแล้วตั้งแต่ตอนที่คุณลงชื่อเข้าใช้หนังสือเล่มนี้ ซึ่งไม่ว่าคุณจะได้มันมาตอนไหน ที่คุณจำเป็นต้องใช้มากในบทนี้ก็คือ ชื่อผู้ใช้ที่คุณสมัครไว้

เราจะใช้ชื่อผู้ใช้ในบทนี้ว่า tmeasday ซึ่งคุณสามารถเปลี่ยนเป็นชื่อผู้ใช้ของคุณได้เลย

แรกสุดเราจำเป็นต้องมีโครงสร้างไฟล์สำหรับเก็บแพ็คเกจของเราซะก่อน เราทำได้ด้วยคำสั่ง meteor create --package tmeasday:errors ซึ่ง Meteor จะทำการสร้างโฟลเดอร์ชื่อ packages/tmeasday:errors/ ที่มีไฟล์บางไฟล์อยู่ข้างใน เราจะเริ่มต้นด้วยการแก้ไข package.js ไฟล์ที่บอก Meteor ให้รู้ว่าแพ็คเกจจะถูกใช้งานอย่างไร และมีอ็อบเจกต์หรือฟังก์ชันตัวไหนที่ถูกเรียกใช้จากภายนอกได้บ้าง

Package.describe({
  name: "tmeasday:errors",
  summary: "A pattern to display application errors to the user",
  version: "1.0.0"
});

Package.onUse(function (api, where) {
  api.versionsFrom('0.9.0');

  api.use(['minimongo', 'mongo-livedata', 'templating'], 'client');

  api.addFiles(['errors.js', 'errors_list.html', 'errors_list.js'], 'client');

  if (api.export) 
    api.export('Errors');
});
packages/tmeasday:errors/package.js

ในการพัฒนาแพ็คเกจเพื่อการใช้งานจริงนั้น คุณควรกรอกข้อมูลลงในเซ็คชั่น git ของบล็อก Package.describe ด้วยตำแหน่ง URL ของ Git repository ของคุณ (เช่น https://github.com/tmeasday/meteor-errors.git) ซึ่งจะทำให้ผู้ใช้งานสามารถเข้าไปดูซอร์สโค้ด และไฟล์ readme ของแพ็คเกจคุณ (สมมุติว่าคุณใช้ GitHub) ก็จะถูกนำมาแสดงใน Atmosphere ด้วย

เราจะเพิ่มไฟล์ 3 ไฟล์เข้าไปที่แพ็คเกจ (เราสามารถลบไฟล์ที่ Meteor สร้างให้เราได้) โดยเราสามารถดึงไฟล์นี้มาจาก Microscope โดยไม่ต้องเปลี่ยนอะไรมาก นอกจากตรงที่กำหนด namespace และปรับ API ให้ดูง่ายขึ้นเล็กน้อย

Errors = {
  // Local (client-only) collection
  collection: new Mongo.Collection(null),

  throw: function(message) {
    Errors.collection.insert({message: message, seen: false})
  }
};
packages/tmeasday:errors/errors.js
<template name="meteorErrors">
  <div class="errors">
    {{#each errors}}
      {{> meteorError}}
    {{/each}}
  </div>
</template>

<template name="meteorError">
  <div class="alert alert-danger" role="alert">
    <button type="button" class="close" data-dismiss="alert">&times;</button>
    {{message}}
  </div>
</template>
packages/tmeasday:errors/errors_list.html
Template.meteorErrors.helpers({
  errors: function() {
    return Errors.collection.find();
  }
});

Template.meteorError.rendered = function() {
  var error = this.data;
  Meteor.setTimeout(function () {
    Errors.collection.remove(error._id);
  }, 3000);
};
packages/tmeasday:errors/errors_list.js

ทดสอบแพ็คเกจด้วย Microscope

ตอนนี้เราจะมาทดสอบแบบโลคอลกันด้วย Microscope เพื่อให้แน่ใจว่าโค้ดที่เราเปลี่ยนไปยังทำงานได้ ด้วยการรันคำสั่ง meteor add tmeasday:errors เพื่อเชื่อมแพ็คเกจเข้ากับแอพของเรา จากนั้นก็ลบไฟล์เดิมที่ซ้ำกับแพ็คเกจใหม่

rm client/helpers/errors.js
rm client/templates/includes/errors.html
rm client/templates/includes/errors.js
removing old files on the bash console

อีกเรื่องนึงที่เราต้องทำคือ อัพเดทโค้ดอีกนิดเพื่อเรียกใช้ API ให้ถูกต้อง

  {{> header}}
  {{> meteorErrors}}
client/templates/application/layout.html
Meteor.call('postInsert', post, function(error, result) {
  if (error) {
    // display the error to the user
    Errors.throw(error.reason);

client/templates/posts/post_submit.js
Posts.update(currentPostId, {$set: postProperties}, function(error) {
  if (error) {
    // display the error to the user
    Errors.throw(error.reason);

  // show this result but route anyway
  if (result.postExists)
    Errors.throw('This link has already been posted');
client/templates/posts/post_edit.js

คอมมิท 9-5-1

Created basic errors package and linked it in.

หลังจากการเปลี่ยนแปลงทั้งหมดนี้แล้ว เราก็ควรจะได้ความสามารถเดิมของแอพก่อนติดตั้งแพคเกจกลับมา

เขียนโค้ดทดสอบ

ขั้นแรกในการพัฒนาแพ็คเกจคือ ทดสอบมันกับแอปพลิเคชั่น ส่วนขั้นต่อไปคือ เขียนโค้ดทดสอบเพื่อทดสอบการทำงานของแพ็คเกจ โดยตัว Meteor มาพร้อมกับ Tinytest (ตัวทดสอบแพ็คเกจ) ซึ่งช่วยให้การรันโค้ดทดสอบทำได้ง่ายๆ และทำให้เราสบายใจเมื่อแบ่งปันแพ็คเกจนี้ให้คนอื่นไปใช้

ลองมาสร้างไฟล์ทดสอบที่ใช้ Tinytest รันการทดสอบกับโค้ดของแพ็คเกจกันดู

Tinytest.add("Errors - collection", function(test) {
  test.equal(Errors.collection.find({}).count(), 0);

  Errors.throw('A new error!');
  test.equal(Errors.collection.find({}).count(), 1);

  Errors.collection.remove({});
});

Tinytest.addAsync("Errors - template", function(test, done) {  
  Errors.throw('A new error!');
  test.equal(Errors.collection.find({}).count(), 1);

  // render the template
  UI.insert(UI.render(Template.meteorErrors), document.body);

  Meteor.setTimeout(function() {
    test.equal(Errors.collection.find({}).count(), 0);
    done();
  }, 3500);
});
packages/tmeasday:errors/errors_tests.js

การทดสอบพวกนี้ เป็นการตรวจสอบพื้นฐานการทำงานของ Meteor.Errors และยังตรวจสอบซ้ำว่าโค้ดที่ถูก rendered ในเทมเพลทยังคงทำงานได้

เราจะไม่พูดลึกลงไปถึงการเขียนโค้ดเพื่อทดสอบแพ็คเกจของ Meteor (เพราะว่า API ยังไม่นิ่งและเข้าที่เข้าทางเท่าไหร่) แต่ก็หวังว่า โค้ดได้อธิบายการทำงานด้วยตัวมันเองไว้พอสมควรอยู่แล้ว

การกำหนดให้ Meteor รันการทดสอบอย่างไรนั้น ให้ใช้โค้ดต่อไปนี้ในไฟล์ package.js

Package.onTest(function(api) {
  api.use('tmeasday:errors', 'client');
  api.use(['tinytest', 'test-helpers'], 'client');  

  api.addFiles('errors_tests.js', 'client');
});
packages/tmeasday:errors/package.js

คอมมิท 9-5-2

Added tests to the package.

จากนั้นเราก็รันคำสั่งทดสอบด้วย

meteor test-packages tmeasday:errors
Terminal
Passing all tests
Passing all tests

ปล่อยแพ็คเกจ

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

โชคดีที่มันง่ายมากๆ เราแค่ cd เข้าไปในโฟลเดอร์ของแพ็คเกจ แล้วรันคำสั่ง meteor publish --create

cd packages/tmeasday:errors
meteor publish --create
Terminal

เมื่อแพ็คเกจถูกปล่อยออกไปแล้ว เราก็สามารถลบมันออกจากแอพ และเพิ่มมันเข้าไปที่แอพตรงๆแบบนี้

rm -r packages/errors
meteor add tmeasday:errors
Terminal (run from the top level of the app)

คอมมิท 9-5-4

Removed package from development tree.

ตอนนี้เราก็เห็น Meteor โหลดแพ็คเกจของเราเป็นครั้งแรกแล้ว เยี่ยมมาก!

และก็เหมือนกับบทแทรกอื่นๆ ที่ีคุณต้องแน่ใจว่าได้ยกเลิกการเปลี่ยนแปลงที่เกิดขึ้น ก่อนที่จะไปต่อ (หรือไม่คุณก็ต้องจัดการกับมันเมื่อคุณทำตามเรื่องที่เหลือของหนังสือนี้)

ข้อคิดเห็น

10

เป้าหมายของเว็บข่าวสังคมก็คือ การสร้างชุมชนผู้ใช้ ซึ่งจะเป็นเรื่องยากหากไม่มีช่องทางให้ผู้คนพูดคุยกันได้ ดังนั้นในบทนี้เราจะมาเพิ่มข้อคิดเห็นกัน !

เราจะเริ่มด้วยการสร้างคอลเลกชั่นใหม่เพื่อเก็บข้อคิดเห็น และตามด้วยการสร้างข้อมูลพื้นฐานบางส่วนใส่เข้าไปในคอลเลกชั่น

Comments = new Mongo.Collection('comments');
lib/collections/comments.js
// Fixture data
if (Posts.find().count() === 0) {
  var now = new Date().getTime();

  // create two users
  var tomId = Meteor.users.insert({
    profile: { name: 'Tom Coleman' }
  });
  var tom = Meteor.users.findOne(tomId);
  var sachaId = Meteor.users.insert({
    profile: { name: 'Sacha Greif' }
  });
  var sacha = Meteor.users.findOne(sachaId);

  var telescopeId = Posts.insert({
    title: 'Introducing Telescope',
    userId: sacha._id,
    author: sacha.profile.name,
    url: 'http://sachagreif.com/introducing-telescope/',
    submitted: new Date(now - 7 * 3600 * 1000)
  });

  Comments.insert({
    postId: telescopeId,
    userId: tom._id,
    author: tom.profile.name,
    submitted: new Date(now - 5 * 3600 * 1000),
    body: 'Interesting project Sacha, can I get involved?'
  });

  Comments.insert({
    postId: telescopeId,
    userId: sacha._id,
    author: sacha.profile.name,
    submitted: new Date(now - 3 * 3600 * 1000),
    body: 'You sure can Tom!'
  });

  Posts.insert({
    title: 'Meteor',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://meteor.com',
    submitted: new Date(now - 10 * 3600 * 1000)
  });

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: new Date(now - 12 * 3600 * 1000)
  });
}
server/fixtures.js

และก็ต้องไม่ลืมที่จะเผยแพร่และบอกรับข้อมูลให้กับคอลเลกชั่นใหม่นี้ด้วย

Meteor.publish('posts', function() {
  return Posts.find();
});

Meteor.publish('comments', function() {
  return Comments.find();
});
server/publications.js
Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() {
    return [Meteor.subscribe('posts'), Meteor.subscribe('comments')];
  }
});
lib/router.js

คอมมิท 10-1

Added comments collection, pub/sub and fixtures.

จำไว้ว่า ถ้าเราต้องการให้โค้ดสร้างข้อมูลชุดนี้ทำงาน เราจำเป็นต้องสั่ง meteor reset เพื่อลบข้อมูลทั้งหมด หลังจากรีเซ็ทแล้ว อย่าลืมสร้างบัญชีผู้ใช้ใหม่แล้วล็อกอินด้วยล่ะ !

ขั้นแรก เราสร้างข้อมูลผู้ใช้ (หลอกๆ) ขึ้นมาสองสามรายการ ใส่มันเข้าไปในฐานข้อมูล แล้วนำค่า id ที่ได้มาสร้างอ็อบเจกต์ผู้ใช้โดยค้นหาจากฐานข้อมูลอีกที จากนั้นก็ใส่ข้อคิดเห็นเข้าไปที่ข่าวแรก โดยเชื่อมข้อคิดเห็นเข้ากับข่าว (ด้วย postId) และกับผู้ใช้ (ด้วย userId) นอกจากนี้เรายังใส่วันที่ป้อนข่าว และเนื้อหาข้อคิดเห็น ตามด้วยชื่อผู้เขียน author ที่เป็นฟิลด์แบบ denormalized ด้วย

อีกทั้งเรายังบอกตัวจัดการเส้นทางให้รอข้อมูล อาร์เรย์ ที่ได้จากการบอกรับข้อมูลข้อคิดเห็นและข้อมูลข่าวด้วย

แสดงข้อคิดเห็น

ข้อคิดเห็นก็ถูกใส่เข้าไปในฐานข้อมูลแล้ว ที่เราต้องทำต่อคือ ดึงมันออกมาแสดงในหน้าข่าวด้วยวิธีที่ตอนนี้คุณคงคุ้นเคยกันแล้ว และพอจะรู้ว่า ขั้นตอนต่อไปเราต้องทำอะไรกันบ้าง

<template name="postPage">
  <div class="post-page page">
    {{> postItem}}

    <ul class="comments">
      {{#each comments}}
        {{> commentItem}}
      {{/each}}
    </ul>
  </div>
</template>
client/templates/posts/post_page.html
Template.postPage.helpers({
  comments: function() {
    return Comments.find({postId: this._id});
  }
});
client/templates/posts/post_page.js

เราใส่บล็อกตัวช่วย {{#each comments}} ลงในเทมเพลทหน้าข่าว ดังนั้น this ในตัวช่วย comments ก็คือ ข่าวที่โพสท์ การค้นหาข้อคิดเห็นของข่าวทำได้โดยดึงข้อคิดเห็นที่เชื่อมโยงกับข่าวนั้นผ่านฟิลด์ postId

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

<template name="commentItem">
  <li>
    <h4>
      <span class="author">{{author}}</span>
      <span class="date">on {{submittedText}}</span>
    </h4>
    <p>{{body}}</p>
  </li>
</template>
client/templates/comments/comment_item.html

จากนั้นก็สร้างตัวช่วยเทมเพลทที่ใช้เปลี่ยนรูปแบบของวันที่ submitted ให้ดูง่ายขึ้น

Template.commentItem.helpers({
  submittedText: function() {
    return this.submitted.toString();
  }
});
client/templates/comments/comment_item.js

โดยเราจะแสดงจำนวนข้อคิดเห็นของแต่ละข่าวดังนี้

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
      <p>
        submitted by {{author}},
        <a href="{{pathFor 'postPage'}}">{{commentsCount}} comments</a>
        {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
      </p>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn btn-default">Discuss</a>
  </div>
</template>
client/templates/posts/post_item.html

ด้วยตัวช่วย commentsCount ใน post_item.js แบบนี้

Template.postItem.helpers({
  ownPost: function() {
    return this.userId === Meteor.userId();
  },
  domain: function() {
    var a = document.createElement('a');
    a.href = this.url;
    return a.hostname;
  },
  commentsCount: function() {
    return Comments.find({postId: this._id}).count();
  }
});
client/templates/posts/post_item.js

คอมมิท 10-2

Display comments on `postPage`.

ตอนนี้คุณก็สามารถแสดงข้อคิดเห็นจากข้อมูลที่เราใส่ไว้ได้ และเห็นหน้าจอคล้ายๆแบบนี้

Displaying comments
Displaying comments

ป้อนข้อคิดเห็น

ตอนนี้เราจะหาทางให้ผู้ใช้สร้างข้อคิดเห็นใหม่ได้ โดยวิธีที่เราทำจะคล้ายๆกับที่เราใช้สร้างข่าวใหม่นั่นเอง

โดยเราจะเริ่มด้วยการเพิ่มกล่องข้อคิดเห็นที่ท้ายข่าว ดังนี้

<template name="postPage">
  <div class="post-page page">
    {{> postItem}}

    <ul class="comments">
      {{#each comments}}
        {{> commentItem}}
      {{/each}}
    </ul>

    {{#if currentUser}}
      {{> commentSubmit}}
    {{else}}
      <p>Please log in to leave a comment.</p>
    {{/if}}
  </div>
</template>
client/templates/posts/post_page.html

และสร้างเทมเพลทของฟอร์มกล่องข้อคิดเห็นแบบนี้

<template name="commentSubmit">
  <form name="comment" class="comment-form form">
    <div class="form-group {{errorClass 'body'}}">
        <div class="controls">
            <label for="body">Comment on this post</label>
            <textarea name="body" id="body" class="form-control" rows="3"></textarea>
            <span class="help-block">{{errorMessage 'body'}}</span>
        </div>
    </div>
    <button type="submit" class="btn btn-primary">Add Comment</button>
  </form>
</template>
client/templates/comments/comment_submit.html

ส่วนการสร้างข้อคิดเห็น เราจะเรียกใช้เมธอด comment จากใน comment_submit.js ที่ทำงานคล้ายๆกับที่เราทำตอนสร้างข่าว

Template.commentSubmit.onCreated(function() {
  Session.set('commentSubmitErrors', {});
});

Template.commentSubmit.helpers({
  errorMessage: function(field) {
    return Session.get('commentSubmitErrors')[field];
  },
  errorClass: function (field) {
    return !!Session.get('commentSubmitErrors')[field] ? 'has-error' : '';
  }
});

Template.commentSubmit.events({
  'submit form': function(e, template) {
    e.preventDefault();

    var $body = $(e.target).find('[name=body]');
    var comment = {
      body: $body.val(),
      postId: template.data._id
    };

    var errors = {};
    if (! comment.body) {
      errors.body = "Please write some content";
      return Session.set('commentSubmitErrors', errors);
    }

    Meteor.call('commentInsert', comment, function(error, commentId) {
      if (error){
        throwError(error.reason);
      } else {
        $body.val('');
      }
    });
  }
});
client/templates/comments/comment_submit.js

ก็เหมือนกับที่เราทำตอนสร้างเมธอด post ที่รันบนฝั่งเซิร์ฟเวอร์ เราจะให้เมธอด comment ของ Meteor เป็นตัวสร้างข้อคิดเห็น โดยทำการตรวจสอบว่าทุกอย่างถูกต้อง แล้วจึงเพิ่มข้อคิดเห็นอันใหม่เข้าไปในคอลเลกชั่น

Comments = new Mongo.Collection('comments');

Meteor.methods({
  commentInsert: function(commentAttributes) {
    check(this.userId, String);
    check(commentAttributes, {
      postId: String,
      body: String
    });

    var user = Meteor.user();
    var post = Posts.findOne(commentAttributes.postId);

    if (!post)
      throw new Meteor.Error('invalid-comment', 'You must comment on a post');

    comment = _.extend(commentAttributes, {
      userId: user._id,
      author: user.username,
      submitted: new Date()
    });

    return Comments.insert(comment);
  }
});
lib/collections/comments.js

คอมมิท 10-3

Created a form to submit comments.

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

The comment submit form
The comment submit form

ควบคุมการบอกรับข้อคิดเห็น

สิ่งที่เกิดขึ้นตอนนี้ก็คือ เรากำลังเผยแพร่ข้อคิดเห็นทั้งหมดของข่าวทุกๆข่าวส่งไปยังไคลเอนต์ทุกตัวที่เชื่อมต่ออยู่ ซึ่งดูแล้วมากเกินความจำเป็น อันที่จริงแล้วในขณะใดขณะหนึ่งเราใช้แค่ข้อมูลชุดย่อยๆเท่านั้น ดังนั้นเราจะมาปรับปรุงวิธีการเผยแพร่และบอกรับข้อมูลโดยกำหนดให้ชัดเจนไปเลยว่า จะเผยแพร่ข้อคิดเห็นตัวไหนส่งออกไปบ้าง

ถ้าลองคิดดู ช่วงเวลาที่เราจำเป็นต้องบอกรับข้อมูลจากการเผยแพร่ของ comments จะเกิดขึ้นแค่ช่วงท่ี่ผู้ใช้งานเปิดเข้าไปดูในหน้าข่าว และเราก็แค่ต้องการโหลดข้อคิดเห็นของข่าวมาเท่านั้น

ขั้นตอนแรก เราจะทำการเปลี่ยนแปลงวิธีบอกรับข้อมูล ซึ่งที่ผ่านมาเราเขียนโค้ดไว้ที่ระดับ ตัวจัดการเส้นทาง หมายความว่า เราโหลดข้อมูลทั้งหมดแค่ครั้งเดียวในตอนที่ตัวจัดการเส้นทางเริ่มทำงาน

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

ผลกระทบที่เกิดตามมาคือ แทนที่เราจะโหลดข้อมูลตั้งแต่ตอนแอพเริ่มทำงาน เราก็จะโหลดมันเมื่อเราเปิดเข้าไปที่ เส้นทาง นั้นแทน หมายความว่า ในตอนนี้คุณจะต้องรอให้มีการโหลดข้อมูลทุกครั้งที่เข้าดูข้อมูลจากแอพ ซึ่งเป็นข้อเสียที่หลีกเลี่ยงไม่ได้ยกเว้นว่าคุณจะโหลดข้อมูลทั้งหมดเข้ามาตั้งแต่ตอนแรก

เราจะเริ่มจาก ยกเลิกการโหลดข้อคิดเห็นในบล็อก configure ด้วยการลบคำสั่ง Meteor.subscribe('comments') (พูดง่ายๆคือ เปลี่ยนกลับไปให้เป็นแบบเดิม)

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() {
    return Meteor.subscribe('posts');
  }
});
lib/router.js

และเพิ่มฟังก์ชัน waitOn ตัวใหม่ที่ระดับ เส้นทาง ของ postPage

//...

Router.route('/posts/:_id', {
  name: 'postPage',
  waitOn: function() {
    return Meteor.subscribe('comments', this.params._id);
  },
  data: function() { return Posts.findOne(this.params._id); }
});

//...
lib/router.js

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

Meteor.publish('posts', function() {
  return Posts.find();
});

Meteor.publish('comments', function(postId) {
  check(postId, String);
  return Comments.find({postId: postId});
});
server/publications.js

คอมมิท 10-4

Made a simple publication/subscription for comments.

ตอนนี้เหลือแค่ปัญหาเดียว เมื่อเรากลับไปที่หน้าโฮม จะเห็นว่าข่าวทุกตัวมีจำนวนข้อคิดเห็นเป็นศูนย์

Our comments are gone!
Our comments are gone!

นับจำนวนข้อคิดเห็น

เดี๋ยวคุณก็รู้ว่าทำไมเราต้องทำ เนื่องจากเราโหลดข้อคิดเห็นเฉพาะที่เส้นทาง postPage เท่านั้น เมื่อเราเรียกใช้คำสั่ง Comments.find({postId: this._id}) ในตัวช่วย commentsCount Meteor จึงไม่สามารถหาผลลัพธ์จากข้อมูลที่ฝั่งไคลเอนต์ให้เราได้

วิธีที่ดีที่สุดที่จะจัดการกับเรื่องนี้คือ ทำการ denormalize จำนวนข้อคิดเห็นไปไว้ที่ข่าว (ถ้าคุณไม่ค่อยแน่ใจว่ามันหมายถึงอะไร ไม่ต้องกังวลไป เราเตรียมเรื่องนี้ไว้ในบทแทรกตอนต่อไปแล้ว !) ซึ่งแม้ว่ามันจะทำให้โค้ดดูซับซ้อนขึ้นนิด แต่ประสิทธิภาพที่เราได้จากการที่ไม่ต้องเผยแพร่ข้อคิดเห็น ทั้งหมด มาที่หน้าแสดงข่าวนั้น คุ้มค่ามากทีเดียว

โดยที่เราจะทำ คือ เพิ่มคุณสมบัติ commentsCount ให้กับโครงสร้างข้อมูล post เริ่มจากการปรับเปลี่ยนโค้ดสร้างข้อมูล (และเรียก meteor reset เพื่อโหลดมันเข้ามาใหม่ แล้วอย่าลืมสร้างบัญชีผู้ใช้ของคุณอีกครั้งด้วย)

// Fixture data
if (Posts.find().count() === 0) {
  var now = new Date().getTime();

  // create two users
  var tomId = Meteor.users.insert({
    profile: { name: 'Tom Coleman' }
  });
  var tom = Meteor.users.findOne(tomId);
  var sachaId = Meteor.users.insert({
    profile: { name: 'Sacha Greif' }
  });
  var sacha = Meteor.users.findOne(sachaId);

  var telescopeId = Posts.insert({
    title: 'Introducing Telescope',
    userId: sacha._id,
    author: sacha.profile.name,
    url: 'http://sachagreif.com/introducing-telescope/',
    submitted: new Date(now - 7 * 3600 * 1000),
    commentsCount: 2
  });

  Comments.insert({
    postId: telescopeId,
    userId: tom._id,
    author: tom.profile.name,
    submitted: new Date(now - 5 * 3600 * 1000),
    body: 'Interesting project Sacha, can I get involved?'
  });

  Comments.insert({
    postId: telescopeId,
    userId: sacha._id,
    author: sacha.profile.name,
    submitted: new Date(now - 3 * 3600 * 1000),
    body: 'You sure can Tom!'
  });

  Posts.insert({
    title: 'Meteor',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://meteor.com',
    submitted: new Date(now - 10 * 3600 * 1000),
    commentsCount: 0
  });

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: new Date(now - 12 * 3600 * 1000),
    commentsCount: 0
  });
}
server/fixtures.js

ก็เหมือนอย่างเคย เมื่อคุณเปลี่ยนแปลงโค้ดสร้างข้อมูล คุณก็ต้องเรียก meteor reset กับฐานข้อมูลคุณ เพื่อให้แน่ใจว่ามันถูกรันอีกครั้ง

จากนั้นเราก็ทำให้แน่ใจว่า ข่าวใหม่ทุกตัวจะมีจำนวนข้อคิดเห็นเป็นศูนย์

//...

var post = _.extend(postAttributes, {
  userId: user._id,
  author: user.username,
  submitted: new Date(),
  commentsCount: 0
});

var postId = Posts.insert(post);

//...
lib/collections/posts.js

และเราก็อัพเดทค่า commentsCount อีกครั้งเมื่อเราสร้างข้อคิดเห็นใหม่ ด้วยการใช้ตัวดำเนินการ $inc ของ Mongo (ที่ใช้เพิ่มค่าฟิลด์ตัวเลขทีละหนึ่ง)

//...

comment = _.extend(commentAttributes, {
  userId: user._id,
  author: user.username,
  submitted: new Date()
});

// update the post with the number of comments
Posts.update(comment.postId, {$inc: {commentsCount: 1}});

return Comments.insert(comment);

//...
lib/collections/comments.js

สุดท้าย เราก็แค่ลบตัวช่วย commentsCount ออกจาก client/templates/posts/post_item.js เนื่องจากเรามีฟิลด์นี้แล้วที่ตัวข่าว

คอมมิท 10-5

Denormalized the number of comments into the post.

ตอนนี้ผู้ใช้ก็สามารถพูดคุยกันได้ แต่มันจะน่าเสียดายแค่ไหนถ้าพวกเค้าพลาดข้อคิดเห็นใหม่ๆ และไม่ว่าคุณจะรู้อะไรมา ในบทต่อไปเราจะแสดงให้คุณเห็นวิธีใช้ การแจ้งเตือน เพื่อป้องกันเรื่องนี้ !

การ Denormalize

Sidebar 10.5

การทำ Denormalize กับข้อมูลคือ การไม่เก็บข้อมูลนั้นในรูปแบบ “ปกติ” โดย denormalize ยังหมายถึงการที่มีข้อมูลเดียวกันหลายๆชุดไว้เอาเรียกใช้งาน

ในบทก่อนหน้านี้ เราได้ทำการ denormalize จำนวนข้อคิดเห็น ไปไว้ที่อ็อบเจกต์ข่าว เพื่อเลี่ยงการโหลดข้อคิดเห็นทั้งหมดอยู่ตลอดเวลา ในมุมมองของการทำแบบจำลองข้อมูลนั้น มันเป็นอะไรที่มากเกินไป เพราะเราสามารถทำการนับจำนวนข้อคิดเห็นเพื่อหาคำตอบได้ตลอดเวลาอยู่แล้ว (โดยยังไม่คิดเรื่องประสิทธิภาพ)

การ Denormalize บ่อยๆ หมายถึงงานที่เพิ่มขึ้นของนักพัฒนา ในตัวอย่างของเรานั้น ทุกครั้งที่เราเพิ่มหรือลบข้อคิดเห็น เราก็จำเป็นต้องอัพเดทข้อมูลข่าวที่เกี่ยวข้องเพื่อให้มั่นใจว่าฟิลด์ commentsCount ยังคงมีค่าที่ถูกต้อง และนี่ก็เป็นสาเหตุโดยตรงที่ฐานข้อมูลแบบ relational เช่น MySQL ไม่ค่อยถูกโฉลกกับแนวทางนี้

อย่างไรก็ตาม วิธีการแบบปกติก็มีข้อเสียของมัน นั่นคือ ถ้าไม่มีคุณสมบัติ commentsCount เราก็จำเป็นต้องส่งข้อคิดเห็น ทั้งหมด มาตลอดเวลา เพียงเพื่อให้สามารถนับจำนวนมันได้เท่านั้น ซึ่งเป็นสิ่งที่เราทำในตอนแรก และการ Denormalize ช่วยเราให้หลีกเลี่ยงเรื่องแบบนี้ได้

การเผยแพร่แบบพิเศษ

มัน เป็น ไปได้ที่จะสร้างการเผยแพร่แบบพิเศษที่ส่งแค่จำนวนข้อคิดเห็นที่เราสนใจมาให้ (เช่น นับจำนวนข้อคิดเห็นของข่าวทั้งหมดที่เรากำลังดูอยู่ ด้วยการรันการสืบค้นที่เซิร์ฟเวอร์)

แต่ก็ควรต้องพิจารณาด้วยว่า ความซับซ้อนของโค้ดการเผยแพร่แบบนั้น ก็ไม่ควรจะมากไปกว่าความยากที่เกิดจากการ denormalize

แน่นอนที่สุดว่า การพิจาณานั้นขึ้นอยู่กับชนิดของแอปพลิเคชั่นด้วย ถ้าคุณเขียนโค้ดที่ยึดว่าความเป็นหนึ่งเดียวของข้อมูลเป็นสิ่งสำคัญ การหลีกเลี่ยงข้อมูลที่ไม่เป็นหนึ่งเดียวกันนั้นก็สำคัญมากกว่า และสำคัญยิ่งกว่าประสิทธิภาพที่จะได้รับเพิ่มขึ้นด้วย

การฝังเอกสารลงไป หรือ การใช้หลายคอลเลกชั่น

ถ้าคุณมีประสบการณ์กับ Mongo คุณอาจประหลาดใจที่เราสร้างคอลเลกชั่นอีกตัวเพียงเพื่อจะเก็บข้อคิดเห็น ทำไมถึงไม่ฝังข้อคิดเห็นลงไปในลิสต์ข้างในเอกสารข่าวซะเลย?

เห็นได้ชัดว่า เครื่องมือหลายๆตัวของ Meteor ช่วยให้เราทำงานได้ดีขึ้นเมื่อทำงานที่ระดับของคอลเลกชั่น อย่างเช่น

  1. ตัวช่วย {{#each}} ทำงานได้อย่างมีประสิทธิภาพมาก เมื่อทำงานซ้ำกับเคอร์เซอร์ (ผลลัพธ์ของคำสั่ง collection.find()) แต่จะไม่ได้ผลเหมือนเดิม ถ้าให้ทำซ้ำกับอาร์เรย์ของอ็อบเจกต์ภายในเอกสารที่ใหญ่ขึ้น
  2. allow และ deny ทำงานที่ระดับเอกสาร และนั่นทำให้เป็นการง่ายที่จะทำให้แน่ใจว่า การเปลี่ยนแปลงใดๆกับข้อคิดเห็นแต่ละข้อเป็นเรื่องถูกต้อง ในมุมมองที่ว่ามันจะซับซ้อนมากขึ้นเมื่อเราทำงานที่ระดับของข่าว
  3. DDP ทำงานที่ระดับของแอททริบิวต์ในระดับบนของเอกสาร ซึ่งอาจมีความหมายว่า ถ้า comments เป็นคุณสมบัติหนึ่งของ post ทุกครั้งที่ข้อคิดเห็นถูกสร้างขึ้นในหน้าข่าว เซิร์ฟเวอร์ก็จะส่งข้อคิดเห็นทั้งหมดของข่าวที่อัพเดทแล้วกลับไปที่ไคลเอนต์แต่ละตัวที่เชื่อมต่ออยู่
  4. การเผยแพร่และบอกรับข้อมูล เป็นงานที่ง่ายกว่ามาก ถ้าทำงานในระดับของเอกสาร ตัวอย่างเช่น ถ้าเราต้องการแบ่งหน้าข้อคิดเห็นในหน้าข่าว เราอาจพบว่ามันยากที่จะทำ ยกเว้นถ้าข้อคิดเห็นอยู่ในคอลเลกชั่นของมันเอง

Mongo แนะนำให้ฝังเอกสารไว้ข้างใน เพื่อหลีกเลี่ยงจำนวนการสืบค้นที่มากเกินไปในการดึงเอกสารมาใช้หลายตัว อย่างไรก็ดี เรื่องนี้แทบจะไม่เป็นปัญหาถ้าเรามองว่า ด้วยสถาปัตยกรรมของ Meteor นั้น เราทำการสืบค้นข้อคิดเห็นที่ ไคลเอนต์ เป็นส่วนใหญ่ โดยไม่ต้องเข้าถึงฐานข้อมูลเลย

ข้อเสียของการ Denormalize

มีข้อโต้แย้งที่ดีว่า คุณ ไม่ควร denormalize ข้อมูลของคุณ เพื่อให้คุณเข้าใจเรื่องที่แย้งกับการ denormalize เราแนะนำให้คุณอ่าน Why You Should Never Use MongoDB เขียนโดย Sarah Mei

การแจ้งเตือน

11

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

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

คุณสมบัติแบบนี้เป็นจุดเด่นของ Meteor เพราะว่าตัว Meteor เองนั้นเป็นเรียลไทม์อยู่แล้ว ทำให้เราสามารถแสดงการแจ้งเตือนได้ ทันที ไม่จำเป็นต้องคอยให้ผู้ใช้รีเฟรชหน้าจอ หรือเช็คอินอะไรอีก ที่เราต้องทำก็แค่ แสดงกล่องข้อความเตือนโดยไม่ต้องใช้โค้ดพิเศษอะไรเพิ่มเติม

สร้างการแจ้งเตือน

เราจะสร้างการแจ้งเตือนเมื่อมีบางคนป้อนข้อคิดเห็นลงในข่าวของคุณ ซึ่งการแจ้งเตือนนี้สามารถขยายขอบเขตการใช้งานได้อีกหลากหลาย แต่สำหรับตอนนี้เราแค่เตือนให้ผู้ใช้รู้ว่าเกิดอะไรขึ้นก็พอ

เราจะสร้างคอลเลกชั่น Notifications พร้อมกับฟังก์ชั่น createCommentNotification ที่ใช้เพิ่มข้อมูลการแจ้งเตือน เมื่อมีข้อคิดเห็นใหม่ๆ เกิดขึ้นตรงกับข่าวใดข่าวหนึ่งของคุณ

เนื่องจากเราจะทำการอัพเดทการแจ้งเตือนจากฝั่งไคลเอนต์ เราก็ต้องแน่ใจว่าได้กำหนดสิทธิ์ allow ที่เข้มงวดพอ โดยเราจะตรวจสอบในเรื่องต่อไปนี้

  • ผู้ใช้ที่สั่ง update เป็นเจ้าของการแจ้งเตือนที่กำลังถูกแก้ไข
  • ผู้ใช้กำลังพยายามอัพเดทแค่หนึ่งฟิลด์
  • หนึ่งฟิลด์นั้นก็คือ คุณสมบัติ read ของการแจ้งเตือน
Notifications = new Mongo.Collection('notifications');

Notifications.allow({
  update: function(userId, doc, fieldNames) {
    return ownsDocument(userId, doc) && 
      fieldNames.length === 1 && fieldNames[0] === 'read';
  }
});

createCommentNotification = function(comment) {
  var post = Posts.findOne(comment.postId);
  if (comment.userId !== post.userId) {
    Notifications.insert({
      userId: post.userId,
      postId: post._id,
      commentId: comment._id,
      commenterName: comment.author,
      read: false
    });
  }
};
lib/collections/notifications.js

ก็เหมือนกับเรื่องข่าวและข้อคิดเห็น คอลเลกชั่น Notifications จะถูกแชร์ให้ใช้งานได้ทั้งที่ฝั่งไคลเอนต์และเซิร์ฟเวอร์ และเราก็จำเป็นต้องอัพเดทข้อมูลการแจ้งเตือนหลังจากที่ผู้ใช้เห็นข้อความแล้ว โดยเราเปิดให้มีการอัพเดทแบบจำกัดสิทธิ์การใช้งานกับข้อมูลที่เป็นของผู้ใช้เท่านั้น

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

เนื่องจากเราสร้างข้อคิดเห็นด้วยเมธอดที่รันบนเซิร์ฟเวอร์ ดังนั้นเราก็สามารถปรับให้เมธอดนั้นมาเรียกใช้ฟังก์ชันของเราได้ โดยเปลี่ยน return Comments.insert(comment); ไปเป็น comment._id = Comments.insert(comment) เพื่อเก็บค่า _id ของข้อคิดเห็นใหม่ไว้ในตัวแปร แล้วก็เรียกฟังก์ชัน createCommentNotification ของเราดังนี้

Comments = new Mongo.Collection('comments');

Meteor.methods({
  commentInsert: function(commentAttributes) {

    //...

    comment = _.extend(commentAttributes, {
      userId: user._id,
      author: user.username,
      submitted: new Date()
    });

    // update the post with the number of comments
    Posts.update(comment.postId, {$inc: {commentsCount: 1}});

    // create the comment, save the id
    comment._id = Comments.insert(comment);

    // now create a notification, informing the user that there's been a comment
    createCommentNotification(comment);

    return comment._id;
  }
});
lib/collections/comments.js

และทำการเผยแพร่ข้อมูลการแจ้งเตือน ดังนี้

Meteor.publish('posts', function() {
  return Posts.find();
});

Meteor.publish('comments', function(postId) {
  check(postId, String);
  return Comments.find({postId: postId});
});

Meteor.publish('notifications', function() {
  return Notifications.find();
});
server/publications.js

โดยบอกรับข้อมูลที่ไคลเอนต์แบบนี้

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() {
    return [Meteor.subscribe('posts'), Meteor.subscribe('notifications')]
  }
});
lib/router.js

คอมมิท 11-1

Added basic notifications collection.

แสดงการแจ้งเตือน

ตอนนี้เราก็พร้อมที่จะเพิ่มรายการแจ้งเตือนเข้าไปที่ header แล้ว

<template name="header">
  <nav class="navbar navbar-default" role="navigation">
    <div class="navbar-header">
      <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
        <span class="sr-only">Toggle navigation</span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </button>
      <a class="navbar-brand" href="{{pathFor 'postsList'}}">Microscope</a>
    </div>
    <div class="collapse navbar-collapse" id="navigation">
      <ul class="nav navbar-nav">
        {{#if currentUser}}
          <li>
            <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
          </li>
          <li class="dropdown">
            {{> notifications}}
          </li>
        {{/if}}
      </ul>
      <ul class="nav navbar-nav navbar-right">
        {{> loginButtons}}
      </ul>
    </div>
  </nav>
</template>
client/templates/includes/header.html

และสร้างเทมเพลท notifications และ notificationItem (โดยใช้ notifications.html เพียงไฟล์เดียว)

<template name="notifications">
  <a href="#" class="dropdown-toggle" data-toggle="dropdown">
    Notifications
    {{#if notificationCount}}
      <span class="badge badge-inverse">{{notificationCount}}</span>
    {{/if}}
    <b class="caret"></b>
  </a>
  <ul class="notification dropdown-menu">
    {{#if notificationCount}}
      {{#each notifications}}
        {{> notificationItem}}
      {{/each}}
    {{else}}
      <li><span>No Notifications</span></li>
    {{/if}}
  </ul>
</template>

<template name="notificationItem">
  <li>
    <a href="{{notificationPostPath}}">
      <strong>{{commenterName}}</strong> commented on your post
    </a>
  </li>
</template>
client/templates/notifications/notifications.html

โดยแผนที่วางไว้ก็คือ ในแต่ละการแจ้งเตือนจะมีลิงก์ไปยังหน้าข่าวที่ถูกป้อนข้อคิดเห็น และมีชื่อผู้ที่ป้อนแสดงอยู่ด้วย

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

Template.notifications.helpers({
  notifications: function() {
    return Notifications.find({userId: Meteor.userId(), read: false});
  },
  notificationCount: function(){
    return Notifications.find({userId: Meteor.userId(), read: false}).count();
  }
});

Template.notificationItem.helpers({
  notificationPostPath: function() {
    return Router.routes.postPage.path({_id: this.postId});
  }
});

Template.notificationItem.events({
  'click a': function() {
    Notifications.update(this._id, {$set: {read: true}});
  }
});
client/templates/notifications/notifications.js

คอมมิท 11-2

Display notifications in the header.

คุณอาจจะคิดว่า การแจ้งเตือนไม่ได้แตกต่างอะไรกับข้อผิดพลาด ซึ่งมันก็จริงที่ว่า โครงสร้างของพวกมันคล้ายคลึงกันมาก แต่มีสิ่งหนึ่งที่แตกต่างออกไปคือ เราได้สร้างคอลเลกชั่นที่ซิงโครไนซ์ข้อมูลระหว่างไคลเอนต์-เซิร์ฟเวอร์อย่างสมบูรณ์ขึ้นมา นั่นหมายความว่า ตราบใดที่เรายังใช้บัญชีผู้ใช้เดิม การแจ้งเตือนจะยังคงอยู่ต่อไป ไม่ว่าจะรีเฟรชสักกี่ครั้ง และบนอุปกรณ์ไหนก็ตาม

ลองทดสอบกันดูหน่อย ให้เปิดเบราว์เซอร์ตัวที่สอง (สมมุติว่า fiirefox) สร้างบัญชีผู้ใช้ใหม่ และป้อนข้อคิดเห็นลงในข่าวที่สร้างไว้ด้วยชื่อผู้ใช้คนแรก (ที่คุณป้อนไว้ใน Chrome) คุณก็ควรจะเห็นหน้าจอแบบนี้

Displaying notifications.
Displaying notifications.

ควบคุมการเข้าใช้การแจ้งเตือน

การแจ้งเตือนทำงานได้ดี แต่ทว่ามีปัญหาเล็กน้อย คือ การแจ้งเตือนของเราเป็นแบบสาธาณะ (public)

ถ้าคุณยังเปิดเบราว์เซอร์ตัวที่สองทิ้งไว้ ให้ลองรันคำสั่งต่อไปนี้ในคอนโซลของเบราว์เซอร์ดู

 Notifications.find().count();
1
Browser console

ผู้ใช้รายใหม่นี้ (คนที่เพิ่ง ป้อนข้อคิดเห็น) ไม่ควรจะเห็นการแจ้งเตีอนตัวไหนเลย การแจ้งเตือนที่เค้ามองเห็นจากคอลเล็กชั่น Notifications จริงๆแล้วเป็นของผู้ใช้ คนแรก

นอกเหนือจากปัญหาเรื่องความเป็นส่วนตัวแล้ว เราคงไม่สามารถทำให้การแจ้งเตือนของผู้ใช้ทุกคนถูกโหลดไปที่เบราว์เซอร์ทุกตัวของผู้ใช้ทั้งหมดได้ ในเว็บไซต์ขนาดใหญ่นั้น อาจทำให้หน่วยความจำของเบราว์เซอร์ไม่เพียงพอ และทำให้เกิดปัญหาใหญ่ที่ส่งผลเสียต่อประสิทธิภาพของระบบได้

เราแก้ปัญหานี้ได้ด้วยการ เผยแพร่ข้อมูล โดยเราสามารถเผยแพร่เฉพาะส่วนของคอลเลกชั่นที่ต้องการแชร์ระหว่างเบราว์เซอร์ได้

เพื่อให้สำเร็จตามนั้น เราจำเป็นต้องใช้ค่าเคอร์เซอร์ที่แตกต่างไปจาก Notifications.find() ตัวเดิม สิ่งที่เราต้องการคือ ให้คืนเคอร์เซอร์ที่ตรงกับการแจ้งเตือนของผู้ใช้ปัจจุบัันเท่านั้น

ซึ่งสามารถทำได้ง่ายๆ เพราะฟังก์ชัน publish นั้นมีค่า _id ของผู้ใช้ปัจจุบันเก็บอยู่ที่ this.userId แล้ว

Meteor.publish('notifications', function() {
  return Notifications.find({userId: this.userId, read: false});
});
server/publications.js

คอมมิท 11-3

Only sync notifications that are relevant to the user.

ตอนนี้ถ้าเราตรวจดูจากเบราว์เซอร์ทั้งคู่ เราก็น่าจะเห็นการแจ้งเตือนที่แตกต่างกัน

 Notifications.find().count();
1
Browser console (user 1)
 Notifications.find().count();
0
Browser console (user 2)

ในความเป็นจริง รายการแจ้งเตือนจะเปลี่ยนไปเมื่อคุณล็อกอินเข้าและออกจากแอพ นั่นก็เพราะการเผยแพร่ข้อมูลจะทำงานใหม่โดยอัตโนมัติ เมื่อใดก็ตามที่บัญชีผู้ใช้เปลี่ยนแปลง

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

รีแอคทีฟขั้นสูง

Sidebar 11.5

มันดูเหมือนไม่ค่อยจำเป็นเท่าไหร่นักที่คุณจะเขียนโค้ดแบบที่มีการติดตามความเชื่อมโยง (dependency tracking) ด้วยตัวคุณเอง แต่มันจะมีประโยชน์แน่ ถ้าทำความเข้าใจมัน ด้วยการแกะรอยวิธีการจัดการความเชื่อมโยงเหล่านั้น

ลองนึกดูว่า ถ้าเราต้องการติดตามว่า มีเพื่อนเฟซบุ๊คของเรากี่คนที่กด “liked” ในแต่ละข่าวของ Microscope และสมมุติว่า เราได้จัดการเรื่องวิธีการตรวจสอบตัวตนกับเฟซบุ๊ค เรียกใช้ API และแปลงข้อมูลที่เกี่ยวข้องได้แล้ว โดยทำออกมาเป็นฟังก์ชันบนไคลเอนต์แบบ asynchronous ที่จะคืนค่าจำนวน likes มาให้ ชื่อ getFacevookLikeCount(user, url, callback) เรียบร้อยแล้ว

สิ่งสำคัญที่ต้องจำไว้เกี่ยวกับฟังก์ชันแบบนี้ก็คือ มันทำงานแบบ ไม่เป็นรีแอคทีฟ และ ไม่ใช่เรียลไทม์ แน่ๆ ที่มันทำก็คือ ติดต่อเข้าเฟซบุ๊คทาง HTTP รับข้อมูลบางอย่าง และส่งต่อให้แอปพลิเคชันผ่านฟังก์ชัน callback แบบ asynchronous แต่ฟังก์ชันนั้น จะไม่รันตัวเองซ้ำเมื่อจำนวน liked ที่เฟซบุ๊ค เปลี่ยนไป และ UI ของเราก็ไม่เปลี่ยนแปลงตามข้อมูลช้างหลังด้วย

เพื่อแก้ไขปัญหานี้ เราจะเริ่มด้วยการใช้ฟังก์ชัน setInterval เพื่อเรียกใช้ฟังก์ชันของเราทุกๆ ห้าวินาที

currentLikeCount = 0;
Meteor.setInterval(function() {
  var postId;
  if (Meteor.user() && postId = Session.get('currentPostId')) {
    getFacebookLikeCount(Meteor.user(), Posts.find(postId).url, 
      function(err, count) {
        if (!err)
          currentLikeCount = count;
      });
  }
}, 5 * 1000);

ไม่ว่าตอนไหนก็ตามที่เราเช็คค่าตัวแปร currentLikeCount เราก็คาดว่าจะได้ตัวเลขที่ถูกต้องทุกช่วงห้าวินาที และเราก็สามารถใช้ตัวแปรนี้กับตัวช่วยได้ดังนี้

Template.postItem.likeCount = function() {
  return currentLikeCount;
}

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

แกะรอยการทำงานแบบรีแอคทีฟ (ส่วนประมวลผล)

การทำงานแบบรีแอคทีฟของ Meteor ใช้ตัวกลางที่เรียกว่า ความเชื่อมโยง (dependencies) ซึ่งเป็นโครงสร้างข้อมูลที่ใช้ติดตามส่วนประมวลผล

จากที่เราเห็นในบทแทรกตอนต้นๆ เรื่องรีแอคทีฟ ส่วนประมวลผลก็คือ ส่วนของโค้ดที่ใช้ข้อมูลรีแอคทีฟ ในกรณีของเรา ส่วนประมวลผลได้ถูกสร้างขึ้นสำหรับเทมเพลท postitem และทุกๆตัวช่วยของตัวจัดการเทมเพลทก็มีส่วนประมวลผลของตัวเอง เช่นกัน

เราอาจมองว่า ส่วนประมวลผลก็คือ ส่วนของโค้ดที่ เฝ้าดู ข้อมูลรีแอคทีฟ เมื่อข้อมูลนั้นเปลี่ยนไป ส่วนประมวลผลนี้ก็จะถูกแจ้ง (ผ่านฟังก์ชัน invalidate()) และตัวส่วนประมวลผลเองก็จะตัดสินใจว่า จะต้องทำอะไร

เปลี่ยนตัวแปรให้กลายเป็นฟังก์ชันแบบรีแอคทีฟ

การที่จะเปลี่ยนตัวแปร currentLikeCount ให้เป็นแหล่งข้อมูลแบบรีแอคทีฟได้นั้น เราจำเป็นต้องเก็บข้อมูลการติดตามส่วนประมวลผลทั้งหมดที่เรียกใช้มันเอาไว้ในตัวแปรความเชื่อมโยง (dependency) และต้องเปลี่ยนมันจากที่เป็นตัวแปรให้กลายเป็นฟังก์ชั่น (ที่คืนค่ากลับมา)

var _currentLikeCount = 0;
var _currentLikeCountListeners = new Tracker.Dependency();

currentLikeCount = function() {
  _currentLikeCountListeners.depend();
  return _currentLikeCount;
}

Meteor.setInterval(function() {
  var postId;
  if (Meteor.user() && postId = Session.get('currentPostId')) {
    getFacebookLikeCount(Meteor.user(), Posts.find(postId), 
      function(err, count) {
        if (!err && count !== _currentLikeCount) {
          _currentLikeCount = count;
          _currentLikeCountListeners.changed();
        }
      });
  }
}, 5 * 1000);

ที่เราทำไปคือ สร้างตัวแปรความเชื่อมโยงชื่อ _currentLikeCountListeners ให้ทำหน้าที่ติดตามส่วนประมวลผลทั้งหมดที่มี currentLikeCount() ใช้อยู่ เมื่อเราเปลี่ยนค่าของ _currentLikeCount เราก็เรียกใช้ฟังก์ชัน changed() กับตัวแปรความเชื่อมโยงนั้นด้วย เพื่อแจ้งให้ส่วนประมวลผลที่ถูกติดตามอยู่ทั้งหมดรับทราบ

ซึ่งส่วนประมวลผลเหล่านี้ ก็สามารถทำงานต่อและจัดกับการเปลี่ยนแปลงได้ตามแต่ละกรณีไป

ถ้าดูแล้วรู้สึกว่า จะต้องใช้โค้ดต้นแบบ (boilerplate) จำนวนมาก เพื่อสร้างแหล่งข้อมูลรีแอคทีฟแบบง่ายๆ คุณก็เข้าใจถูกแล้ว ซึ่งตัว Meteor เอง ก็มีเครื่องมือที่ช่วยให้งานนี้ง่ายขึ้น (เปรียบเหมือนกับที่คุณไม่จำเป็นต้องเรียกใช้ส่วนประมวลผลโดยตรง แต่ใช้ autoruns แทน) เป็นแพ็คเกจที่ติดมาเรียกว่า reactive-var ทำงานเหมือนกับที่ฟังก์ชัน currentLikeCount() ทำได้ โดยเราสามารถเพิ่มมันเข้าไป ด้วยคำสั่งนี้

meteor add reactive-var

แล้วเราก็ใช้มันเพื่อให้เขียนโค้ดได้ง่ายขึ้นดังนี้

var currentLikeCount = new ReactiveVar();

Meteor.setInterval(function() {
  var postId;
  if (Meteor.user() && postId = Session.get('currentPostId')) {
    getFacebookLikeCount(Meteor.user(), Posts.find(postId), 
      function(err, count) {
        if (!err) {
          currentLikeCount.set(count);
        }
      });
  }
}, 5 * 1000);

ตอนนี้ถ้าเราจะใช้งาน เราก็ต้องเรียก currentLikeCount.get() จากในตัวช่วย และมันก็จะทำงานได้เหมือนเดิม นอกจากนี้ยังมีอีกแพ็คเกจนึงชื่อ reactive-dict ซึ่งจะมีข้อมูลรีแอคทีฟแบบ key-val มาให้ (เกือบจะเหมือนกับ Session) ที่ใช้ประโยชน์ได้เช่นกัน

เปรียบเทียบ Tracker กับ Angular

Angular เป็นไลบรารี่แบบรีแอคทีฟบนฝั่งไคลเอนต์ตัวหนึ่ง ถูกพัฒนาโดยโปรแกรมเมอร์ที่ Google โดยการเปรียบเทียบระหว่าง วิธีติดตามความเชื่อมโยง (dependency tracking) ของ Meteor กับของ Angular นั้นจะเห็นได้ชัดเจน เนื่องจากทั้งสองใช้แนวทางที่แตกต่างกัน

เราเห็นแล้วว่า โมเดลของ Meteor นั้นใช้บล็อกของโค้ดที่เรียกว่า ส่วนประมวลผล โดยส่วนประมวลผลเหล่านี้จะถูกติดตามการใช้งานจากแหล่งข้อมูล “รีแอคทีฟ” แบบพิเศษ (ฟังก์ชัน) ซึ่งคอย invalidate พวกมันตามความเหมาะสม จากนั้นตัวแหล่งข้อมูลก็จะแจ้ง ตรง ไปยังส่วนประมวลผลที่เชื่อมโยงกันอยู่ (dependencies) เมื่อพวกมันจำเป็นต้องเรียกใช้ invalidate() สังเกตุด้วยว่า แม้โดยทั่วไปเรื่องพวกนี้จะเกิดขึ้นตอนที่ข้อมูลเปลี่ยนแปลง แต่แหล่งข้อมูลก็สามารถตัดสินใจสั่งให้มีการ invalidate ได้ด้วยเหตุผลอื่นเช่นกัน

เพื่มเติมอีกนิด ถึงแม้ว่าส่วนประมวลผลมักจะรันตัวเองใหม่เมื่อถูก invalidate คุณก็สามารถกำหนดให้มันทำตามสิ่งที่คุณต้องการได้ นั่นทำให้เราสามารถควบคุมการทำงานแบบรีแอคทีฟได้มากขึ้น

ใน Angular, การทำงานแบบรีแอคทีฟ จะทำผ่านตัวกลางคือ อ็อบเจกต์ scope ตัว scope อาจมองได้ว่าเป็น อ็อบเจกต์จาวาสคริปต์ตัวนึง ที่มีเมธอดพิเศษชุดหนึ่งติดมาด้วย

เมื่อคุณต้องการให้การทำงานแบบรีแอคทีฟขึ้นอยู่กับค่าใน scope คุณต้องเรียกใช้ scope.$watch ตามด้วยนิพจน์ (expression) ที่คุณสนใจอยู่ (เช่น ส่วนของ scope ที่คุณสนใจ) และฟังก์ชัน listener ที่จะทำงานทุกครั้งเมื่อนิพจน์เปลี่ยนแปลง จากนั้นคุณก็ระบุโค้ดลงไปว่าต้องการให้ทำอะไรทุกครั้งที่นิพจน์มีค่าเปลี่ยนไป

ย้อนกลับไปที่ตัวอย่างเฟซบุ๊คของเรา เราก็สามารถเขียนได้ว่า

$rootScope.$watch('currentLikeCount', function(likeCount) {
  console.log('Current like count is ' + likeCount);
});

อันที่จริง ก็เหมือนกับตอนที่เราสร้างหน่วยประมวลผลใน Meteor คุณมักไม่ค่อยจะได้ใช้ $watch ตรงๆกันมากนักใน Angular ถ้าเทียบกับคำสั่ง ng-model และ {{expressions}} ที่จะสร้างคำสั่ง watch ให้เองโดยอัตโนมัติ เพื่อจัดการเรื่องการแสดงผลใหม่เมื่อมีการเปลี่ยนแปลงเกิดขึ้น

เมื่อข้อมูลที่เป็นรีแอคทีฟถูกเปลี่ยนค่า scope.$apply() ก็จะถูกเรียกใช้ ทำให้ watcher ทุกตัวใน scope ถูกประมวลผลใหม่อีกครั้ง แต่จะเรียกใช้ฟังก์ชัน listener ของ watcher เฉพาะตัวที่ค่านิพจน์ เปลี่ยนแปลง เท่านั้น

นั่นทำให้ scope.$apply() คล้ายกับ dependency.changed() เว้นเสียแต่ว่า มันทำงานในระดับของ scope มากกว่าที่จะยอมให้คุณควบคุมว่า ฟังก์ชัน listener ตัวไหนจะถูกประมวลผลใหม่ จากที่กล่าวมาจะเห็นได้ว่า การลดการควบคุมลง ทำให้ Angular ต้องมีความฉลาดมากขึ้น และต้องมีประสิทธิภาพดีพอที่จะเลือกได้ว่าฟังก์ชัน listener ตัวไหนจะถูกเรียกให้ประมวลผล

ถ้าเราใช้วิธีแบบ Angular โค้ดในฟังก์ชัน getFacebookLikeCount() ก็จะมีหน้าตาคล้ายๆแบบนี้

Meteor.setInterval(function() {
  getFacebookLikeCount(Meteor.user(), Posts.find(postId), 
    function(err, count) {
      if (!err) {
        $rootScope.currentLikeCount = count;
        $rootScope.$apply();
      }
    });
}, 5 * 1000);

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

การแบ่งหน้า

12

ทุกสิ่งทุกอย่างดูเยี่ยมไปหมดกับ Microscope และเราก็หวังว่าผู้คนทั้งหลายจะชื่นชอบมัน เมื่อถึงเวลาเปิดตัวสู่สายตาชาวโลก

สิ่งที่เราควรคิดถึงในตอนนี้ น่าจะเป็นเรื่องจำนวนข่าวที่จะเข้ามาในไซต์ว่าจะส่งผลต่อประสิทธิภาพอย่างไรตอนที่เริ่มเปิดตัว !

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

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

เพิ่มข่าวเข้าไปอีก

สิ่งแรกที่เราจะทำ คือ ปรับข้อมูลตั้งต้น โดยให้โหลดข่าวเข้ามามากพอที่จะทำให้การแบ่งหน้าดูมีเหตุผล

// Fixture data 
if (Posts.find().count() === 0) {

  //...

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: new Date(now - 12 * 3600 * 1000),
    commentsCount: 0
  });

  for (var i = 0; i < 10; i++) {
    Posts.insert({
      title: 'Test post #' + i,
      author: sacha.profile.name,
      userId: sacha._id,
      url: 'http://google.com/?q=test-' + i,
      submitted: new Date(now - i * 3600 * 1000),
      commentsCount: 0
    });
  }
}
server/fixtures.js

หลังจากรัน meteor reset และสั่งให้แอพเริ่มทำงานอีกคร้้ง คุณก็น่าจะเห็นอะไรแบบนี้

Displaying dummy data.
Displaying dummy data.

คอมมิท 12-1

Added enough posts that pagination is necessary.

แบ่งหน้าแบบไม่รู้จบ

เราจะสร้างการแบ่งหน้าแบบ “ไม่รู้จบ” ที่เราพูดแบบนั้นก็เพราะว่า ตอนแรกเราจะแสดงข่าวซัก 10 ข่าว และในหน้าจอก็จะมีลิงก์ “โหลดเพิ่ม” อยู่ด้านล่าง เมื่อคลิ๊กที่ลิงก์ก็จะมีข่าวเพิ่มเข้ามาอีก 10 และเป็นอย่างนี้ไปเรื่อยๆ แบบไม่รู้จบ โดยเราจะควบคุมการแบ่งหน้าทั้งหมดด้วยค่าพารามิเตอร์ตัวเดียว ใช้แทนจำนวนข่าวที่จะแสดงในหน้าจอ

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

วิธีที่ง่ายที่สุดคือ ใส่พารามิเตอร์ของจำนวนข่าวที่ต้องการเข้าไปในพาธเส้นทาง ทำให้ URL ของเราเป็นแบบนี้ http://localhost:3000/25 ข้อดีของการใช้ URL เมื่อเทียบกับเมธอดคือ ถ้าคุณกำลังดูข่าวอยู่ 25 รายการ จากนั้นเผลอไปรีโหลดเบราว์เซอร์ คุณก็ยังคงเห็นข่าวแค่ 25 รายการเมื่อมันโหลดเสร็จ

แนวทางที่ควรทำคือ เราต้องเปลี่ยนวิธีที่เราบอกรับข่าว ให้เป็นเหมือนกับที่เราทำก่อนหน้านี้ในบท ข้อคิดเห็น โดยเราต้องย้ายโค้ดการบอกรับจากระดับ ตัวจัดการเส้นทาง มาไว้ที่ระดับ เส้นทาง แทน

ฟังดูเหมือนมีอะไรมากมายที่ต้องทำในครั้งเดียว แต่เมื่อเห็นโค้ดคุณก็จะเข้าใจได้

ขั้นตอนแรก เราต้องหยุดการบอกรับข้อมูล posts ในบล็อก Router.configure() โดยแค่ลบ Meteor.subscribe('posts') ออกไป ให้เหลือเพียงแค่การบอกรับ notifications เท่านั้น

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() { 
    return [Meteor.subscribe('notifications')]
  }
});
lib/router.js

จากนั้นเราจะเพิ่มพารามิเตอร์ postsLimit ไปที่พาธของเส้นทาง โดยใส่ ? หลังชื่อพารามิเตอร์เพื่อบอกว่า จะมีหรือไม่มีก็ได้ ดังนั้นเส้นทางของเราไม่เพียงแต่จะตรงกับ http://localhost:3000/50 แต่ยังตรงกับของเดิมคือ http://localhost:3000 ได้ด้วย

//...

Router.route('/:postsLimit?', {
  name: 'postsList',
});

//...
lib/router.js

เรื่องสำคัญที่ต้องรู้คือ พาธในรูปแบบ /:parameter? นั้น จะตรงกับทุกๆเส้นทางที่เกิดขึ้นได้ทั้งหมด และเนื่องจากแต่ละเส้นทางที่เรากำหนดจะถูกแปลความหมายทีละตัว เพื่อตรวจดูว่ามันตรงกับพาธปัจจุบันหรือไม่ ดังนั้นเราก็ต้องแน่ใจว่า ได้จัดเส้นทางให้เรียงลำดับลดลงตามความเฉพาะเจาะจงแล้ว

ในอีกนัยหนึ่ง เส้นทางไหนที่กำหนดเป้าหมายไว้เฉพาะเจาะจงมาก เช่น /posts/:_id ควรมาก่อน และเส้นทาง postsList ของเราก็ควรย้าย ไปไว้ข้างล่างสุด ของการกำหนดเส้นทาง เพราะว่ามันตรงกับทุกพาธที่เป็นไปได้

ตอนนี้ก็ได้เวลาที่จะจัดการปัญหายากเรื่องการบอกรับข้อมูลและค้นหาข้อมูลที่ต้องการกันแล้ว โดยเราต้องจัดการกับกรณีที่ไม่มีค่าพารามิเตอร์ postsLimit ด้วย ซึ่งเราจะกำหนดค่าตั้งต้นให้มัน โดยใช้ค่าเป็น 5 เพื่อให้มีช่องว่างพอที่จะลองเล่นกับการแบ่งหน้าได้

//...

Router.route('/:postsLimit?', {
  name: 'postsList',
  waitOn: function() {
    var limit = parseInt(this.params.postsLimit) || 5; 
    return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
  }
});

//...
lib/router.js

คุณน่าจะสังเกตุเห็นว่า เราส่งอ็อบเจกต์จาวาสคริปต์ ({sort: {submitted: -1}, limit: postsLimit}) ไปพร้อมกับชื่อการเผยแพร่ข้อมูล posts ของเรา ซึ่งอ็อบเจกต์ตัวนี้ทำหน้าที่เป็นพารามิเตอร์สำหรับระบุค่าตัวเลือกของคำสั่ง Posts.find() บนฝั่งเซิร์ฟเวอร์ และตอนนี้เราก็จะข้ามมาที่ฝั่งเซิร์ฟเวอร์เพื่อเขียนโค้ดต่อไปนี้

Meteor.publish('posts', function(options) {
  check(options, {
    sort: Object,
    limit: Number
  });
  return Posts.find({}, options);
});

Meteor.publish('comments', function(postId) {
  check(postId, String);
  return Comments.find({postId: postId});
});

Meteor.publish('notifications', function() {
  return Notifications.find({userId: this.userId});
});
server/publications.js

Passing Parameters

โค้ดการเผยแพร่ข้อมูลของเรานั้น ทำหน้าที่แจ้งไปยังเซิร์ฟเวอร์ว่า สามารถเชื่อถืออ็อบเจกต์จาวาสคริปต์ที่ส่งมาจากไคลเอนต์ (ในกรณีนี้คือ {limit: postsLimit}) และใช้เป็นค่า options ของคำสั่ง find() ได้ ทำให้มีความเป็นไปได้ที่ผู้ใช้จะส่งตัวเลือกแบบไหนก็ได้ที่ต้องการผ่านทางคอนโซลของเบราว์เซอร์

ในกรณีของเรานั้น มันแทบจะไม่ส่งผลกระทบอะไร เนื่องจากที่ผู้ใช้สามารถทำได้คือ เรียงลำดับข่าวแบบอื่น หรือเปลี่ยนค่า limit (ที่เราต้องการทำตั้งแต่แรก) เท่านั้น แต่อย่างไรก็ตาม แอพที่ใช้งานจริงก็ควรต้องมีการจำกัดค่าของลิมิตไว้ด้วย!

เรื่องที่ดีก็คือ การใช้ check() ทำให้เรารู้ว่า ผู้ใช้จะไม่สามารถแอบใส่ตัวเลือกอื่นเข้ามาได้ (เช่น fields ที่ในบางกรณีอาจจะเปิดเผยข้อมูลส่วนตัวของเอกสารออกมาได้)

โดยรูปแบบที่ปลอดภัยกว่านั้นคือ ส่งค่าพารามิเตอร์แยกออกมาจากเดิมที่เป็นอ็อบเจกต์ เพื่อให้แน่ใจว่าคุณยังควบคุมข้อมูลไว้ได้

Meteor.publish('posts', function(sort, limit) {
  return Posts.find({}, {sort: sort, limit: limit});
});

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

ความหมายง่ายๆก็คือ แทนที่เราจะใช้ค่า this ที่มีมาให้ภายในเทมเพลท เราก็จะเรียกชุดข้อมูลของเราว่า posts แทน นอกจากส่วนเล็กๆนี้แล้ว โค้ดที่เหลือน่าจะคุ้นเคยกันดีอยู่แล้ว

//...

Router.route('/:postsLimit?', {
  name: 'postsList',
  waitOn: function() {
    var limit = parseInt(this.params.postsLimit) || 5; 
    return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
  },
  data: function() {
    var limit = parseInt(this.params.postsLimit) || 5; 
    return {
      posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
    };
  }
});

//...
lib/router.js

และเนื่องจากเรากำหนดค่าของชุดข้อมูลไว้ที่ระดับเส้นทางแล้ว เราก็สามารถลบตัวช่วยเทมเพลท posts ที่อยู่ใน posts_list.js ออกไปได้ โดยลบโค้ดทั้งหมดในไฟล์ได้เลย

เพราะเราตั้งชื่อชุดข้อมูลว่า posts (ชื่อเดียวกับในตัวช่วย) ดังนั้นเราก็ไม่ต้องแก้ไขอะไรที่เทมเพลท postsList อีก

สรุปแล้วโค้ดของ router.js ที่เราแก้ไขปรับปรุงใหม่ ก็จะมีหน้าตาประมาณนี้

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() { 
    return [Meteor.subscribe('notifications')]
  }
});

Router.route('/posts/:_id', {
  name: 'postPage',
  waitOn: function() {
    return Meteor.subscribe('comments', this.params._id);
  },
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/posts/:_id/edit', {
  name: 'postEdit',
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/submit', {name: 'postSubmit'});

Router.route('/:postsLimit?', {
  name: 'postsList',
  waitOn: function() {
    var limit = parseInt(this.params.postsLimit) || 5; 
    return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
  },
  data: function() {
    var limit = parseInt(this.params.postsLimit) || 5; 
    return {
      posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
    };
  }
});

var requireLogin = function() {
  if (! Meteor.user()) {
    if (Meteor.loggingIn()) {
      this.render(this.loadingTemplate);
    } else {
      this.render('accessDenied');
    }
  } else {
    this.next();
  }
}

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
lib/router.js

คอมมิท 12-2

Augmented the postsList route to take a limit.

ลองมาทดสอบระบบจัดแบ่งหน้าตัวใหม่ของเราดูหน่อย เนื่องจากตอนนี้หน้าโฮมของเราสามารถที่จะแสดงจำนวนโพสท์เท่าไรก็ได้เพียงแค่เปลี่ยนค่าพารามิเตอร์ใน URL อย่างเช่น ลองเรียกดูที่ http://localhost:3000/3 คุณก็น่าจะเห็นอะไรแบบนี้

Controlling the number of posts on the homepage.
Controlling the number of posts on the homepage.

ทำไมไม่ทำเป็นหลายหน้า

ทำไมเราถึงใช้แนวทาง “การแบ่งหน้าแบบไม่รู้จบ” แทนที่จะแสดงหน้าละ 10 ข่าว หลายๆหน้าเรียงกัน คล้ายๆกับที่ Google ใช้แสดงผลการค้นหาล่ะ คำตอบนี้จริงๆแล้วน่าจะมาจากวิธีคิดแบบเรียลไทม์ที่ Meteor นำมาใช้

ลองจินตนาการดูว่า ถ้าเราแบ่งหน้าคอลเลกชั่น Posts โดยใช้รูปแบบการแบ่งหน้าของ Google และเรากำลังอยู่ที่หน้า 2 ซึ่งแสดงข่าวที่ 10 ถึง 20 อะไรจะเกิดขึ้นถ้าผู้ใช้อีกคนลบบางข่าวออกไปจาก 10 ข่าวแรก

เนื่องจากแอพเราเป็นแบบเรียลไทม์ ชุดข้อมูลของเราก็จะเปลี่ยน ข่าวที่ 10 ก็จะกลายเป็นข่าวที่ 9 และหายไปจากหน้าจอเรา ในขณะเดียวกันข่าวที่ 11 ก็จะเข้ามาแทน ผลที่เกิดก็คือ ผู้ใช้จะเห็นว่าข่าวที่เค้าดูอยู่เปลี่ยนแปลงไปโดยไม่มีสาเหตุ !

แม้ว่าเราจะรับได้กับอะไรที่แปลกๆของ UX แบบนี้ การเขียนโค้ดให้แบ่งหน้าในแบบดั้งเดิมก็ค่อนข้างยุ่งยากด้วยปัญหาทางเทคนิคอยู่ดี

ลองกลับไปที่ตัวอย่างก่อนหน้านี้ ถ้าเราเผยแพร่ข่าวตัวที่ 10 ถึง 20 จากคอลเลคชั่น Posts เราจะหาข่าวพวกนี้จากไคลเอนต์ได้ยังไง เราคงไม่สามารถเลือกข่าวตั้งแต่ตัวที่ 10 ถึง 20 ได้ เพราะว่าในฝั่งไคลเอนต์เรามีข่าวแค่ 10 รายการเท่านั้น

มีทางออกนึงคือ เราก็เผยแพร่ข่าว 10 ตัวนั้นบนเซิร์ฟเวอร์ จากนั้นก็เรียก Posts.find() ที่ไคลเอนต์เพื่อดึงข่าวที่ถูกเผยแพร่มา ทั้งหมด

วิธีนี้ใช้การได้ ถ้าคุณมีการบอกรับข้อมูลแค่ตัวเดียว แต่ถ้าคุณเริ่มมีการบอกรับข้อมูลที่มากกว่าหนึ่งตัวเหมือนที่เรากำลังจะทำเร็วๆนี้

ถ้าการบอกรับข้อมูลตัวแรกต้องการข่าวตัวที่ 10 ถึง 20 และการบอกรับอีกตัวต้องการข่าวตัวที่ 30 ถึง 40 คุณก็จะมีข่าวที่โหลดมาไว้บนไคลเอนต์รวมแล้ว 20 ข่าว โดยไม่มีทางรู้ว่าข่าวตัวไหนเป็นของการบอกรับข้อมูลตัวไหน

ด้วยเหตุผลทั้งหมดที่ว่ามานี้ การแบ่งหน้าแบบเดิมก็ไม่เหมาะเท่าไรนักเมื่อนำมาใช้กับ Meteor

สร้างตัวควบคุมเส้นทาง

คุณอาจสังเกตุเห็นว่า เราทำซ้ำที่บรรทัด var limit = parseInt(this.params.postsLimit) || 5; สองครั้ง รวมกับที่เรากำหนดเลข “5” ลงในโค้ด ไม่ใช่วิธีการที่ดีเท่าไหร่ มันอาจจะไม่ใช่จุดจบของโลก แต่การทำตามหลักการ DRY (Don’t Repeat Yourself) ก็น่าจะเป็นอะไรที่ดีกว่า ถ้าคุณทำได้ ตอนนี้เรามาลองดูว่าจะปรับโค้ดตรงนี้ได้ยังไง

เราจะใช้คุณลักษณะใหม่ของ Iron Router คือ ตัวควบคุมเส้นทาง (Route Controllers) ตัวควบคุมเส้นทางคือ วิธีง่ายๆที่จะรวมคุณสมบัติของการจัดเส้นทางหลายๆตัวเข้าด้วยกันในรูปแพ็คเกจที่เส้นทางไหนก็สามารถนำไปใช้ต่อได้ ตอนนี้เราแค่ใช้มันกับเส้นทางเดียว แต่ในบทต่อไปคุณจะเห็นว่าคุณสมบัตินี้ช่วยเราได้ดีทีเดียว

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  postsLimit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.postsLimit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  data: function() {
    return {posts: Posts.find({}, this.findOptions())};
  }
});

//...

Router.route('/:postsLimit?', {
  name: 'postsList'
});

//...
lib/router.js

เรามาลองไล่โค้ดกันดูทีละขั้นตอน ขั้นแรก เราก็สร้างตัวควบคุมของเรา โดยสร้างต่อมาจาก RouteController จากนั้นเราก็ตั้งค่าให้กับคุณสมบัติ template แบบที่เคยทำก่อนหน้า และคุณสมบัติใหม่ increment อีกตัว

ต่อมาเราก็สร้างฟังก์ชันใหม่ postsLimit ซึ่งจะคืนค่า limit ปัจจุบัน และฟังก์ชัน findOptions ซึ่งจะคืนค่าอ็อบเจกต์ตัวเลือก ขั้นตอนนี้ดูเหมือนจะไม่จำเป็น แต่เดี๋ยวเราจะได้ใช้มัน

ต่อไปเราก็สร้างฟังก์ชัน waitOn และ data เหมือนก่อนหน้า ยกเว้นว่า ตอนนี้พวกมันเรียกใช้ ฟังก์ชันใหม่ findOptions ของเราแล้ว

เนื่องจากตัวควบคุมของเราชื่อ PostsListController และเส้นทางเราชื่อ postsList ตัว Iron Router จะใช้ตัวควบคุมของเรากับเส้นทางเองโดยอัตโนมัติ ดังนั้นเราก็ต้องลบ waitOn และ data ออกจากข้อมูลเส้นทาง (เพราะว่าตอนนี้ตัวควบคุมจะจัดการแทน) แต่ถ้าเราใช้ชื่อตัวควบคุมต่างออกไป เราก็สามารถใช้ตัวเลือก controller กำหนดชื่อตัวควบคุมได้ (ซึ่งเราจะเห็นตัวอย่างแบบนี้ในบทต่อไป)

คอมมิท 12-3

Refactored postsLists route into a RouteController.

ใส่ลิงก์ให้ปุ่มโหลดเพิ่ม

เรามีการแบ่งหน้าที่ทำงานได้แล้ว และโค้ดของเราก็ดูใช้ได้ดี แต่มีอีกปัญหานึง คือ เรายังไม่มีวิธีที่จะ เรียกใช้ การแบ่งหน้า ยกเว้นว่าไปเปลี่ยนที่ URL ซะเอง ถ้าเป็นแบบนั้นผู้ใช้ต้องรู้สึกแย่แน่ๆ ดังนั้นเราจะมาแก้ไขตรงนี้กัน

สิ่งที่เราจะทำค่อนข้างง่ายทีเดียว เราจะเพิ่มปุ่ม “โหลดเพิ่ม” ที่ด้านล่างของข่าว ซึ่งจะเพิ่มจำนวนข่าวที่แสดงอยู่ออกไปอีก 5 ทุกครั้งที่ถูกคลิ๊ก สมมุติว่าตอนแรกเราเปิดอยู่ที่ URL http://localhost:3000/5 เมื่อคลิ๊กที่ปุ่ม “โหลดเพิ่ม” ก็จะได้หน้า ‘http://localhost:3000/10` มาแสดง ซึ่งถ้าคุณทำตามหนังสือมาได้ขนาดนี้ เราเชื่อว่าคุณสามารถจัดการตัวเลขพวกนี้ได้สบายๆ !

ก็เหมือนก่อนหน้านี้ เราจะเพิ่มโค้ดการแบ่งหน้าเข้าไปที่เส้นทางของเรา แต่ต้องจำไว้ว่า ตอนนี้เราใช้ชื่อชุดข้อมูลที่เราตั้งเอง ไม่ได้ใช้ตัวไม่มีชื่อที่มาจากเคอร์เซอร์ อันที่จริงก็ไม่มีกฎเกณฑ์อะไรที่บอกว่า ฟังก์ชัน data จะคืนได้แค่ค่า cursor ดังนั้นเราก็จะใช้เทคนิคเดียวกันนี้สร้าง URL ของปุ่ม “โหลดเพิ่ม” ขึ้นมา

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  postsLimit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.postsLimit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  posts: function() {
    return Posts.find({}, this.findOptions());
  },
  data: function() {
    var hasMore = this.posts().count() === this.postsLimit();
    var nextPath = this.route.path({postsLimit: this.postsLimit() + this.increment});
    return {
      posts: this.posts(),
      nextPath: hasMore ? nextPath : null
    };
  }
});

//...
lib/router.js

ตอนนี้เราจะมาเจาะลึกดูความมหัศจรรย์บางส่วนของตัวจัดการเส้นทางกัน เราจำได้ว่าเส้นทาง postsList (ที่สืบทอดต่อมาจากตัวควบคุม PostsListController ที่เรากำลังทำอยู่) รับค่าพารามิเตอร์ postsLimit

เมื่อเราส่งค่า {postsLimit: this.postsLimit() + this.increment} ไปให้ this.route.path() ก็หมายความว่า เรากำลังบอกเส้นทาง postsList ให้สร้างพาธขึ้นมาโดยใช้ข้อมูลจากอ็อบเจกต์จาวาสคริปต์

อีกนัยหนึ่งคือ วิธีการนี้ทำงานเหมือนตอนที่เราใช้ตัวช่วยของ Spacebars {{pathFor 'postsList'}} ยกเว้นว่าเราแทนค่า this ที่ใด้มา ด้วยชุดข้อมูลที่เราสร้างเอง

โดยเราจะนำพาธนั้นมาใส่เข้าไปที่ชุดข้อมูล และส่งต่อให้เทมเพลท แต่เราจะทำ เฉพาะ ในตอนที่มีข่าวต้องแสดงเพิ่มเท่านั้น ซึ่งวิธีที่เราใช้ค่อนข้างเข้าใจยากเล็กน้อย

เรารู้ว่า this.postsLimit() จะคืนจำนวนข่าวที่เราต้องการแสดง ซึ่งอาจมาจากค่าใน URL หรือค่าตั้งต้น (5) ถ้าไม่มีพารามิเตอร์ใน URL

ในขณะที่ตัว this.posts จะอ้างถึงเคอร์เซอร์ที่กำลังใช้งานอยู่ ดังนั้น this.posts.count() ก็คือจำนวนข่าวที่อยู่ใน cursor ตอนนี้

สิ่งที่เรากำลังอธิบายตอนนี้ก็คือ ถ้าเราขอข่าวไป n ตัว และเราได้กลับมา n ตัว เราก็จะแสดงปุ่ม “โหลดเพิ่ม” แต่ถ้าเราขอไป n แต่ได้กลับมา น้อย กว่า n แสดงว่าเราอยู่ที่หน้าสุดท้ายแล้ว และเราก็ไม่ควรแสดงปุ่มอีก

จากที่บอกมา ระบบของเราจะพลาดได้ในกรณีเดียว คือ เมื่อจำนวนข่าวในฐานข้อมูลมีค่าเท่ากับ n พอดี ซึ่งถ้าเกิดเหตุการณ์แบบนั้นขึ้น ไคลเอนต์ขอไป n ตัว และได้กลับมา n ตัว ปุ่ม “โหลดเพิ่ม” ก็ยังแสดงต่อไปได้ โดยไม่รู้ว่าไม่มีข้อมูลเหลืออีกแล้ว

น่าเสียใจที่เรายังไม่มีวิธีแก้ไขปัญหานี้แบบง่ายๆ ดังนั้นในตอนนี้เราก็จำเป็นต้องใช้วิธีที่ยังไม่ค่อยสมบูรณ์นี้ต่อไปก่อน

สิ่งที่เหลือที่จะทำคือใส่ลิงก์ “load more” ที่ด้านล่างของรายการข่าว และทำให้แน่ใจว่าจะแสดงมันเมื่อมีข่าวให้โหลดเพิ่มแล้วเท่านั้น ดังนี้

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{/if}}
  </div>
</template>
client/templates/posts/posts_list.html

ตอนนี้รายการข่าวของคุณก็น่าจะคล้ายๆแบบนี้

The “load more” button.
The “load more” button.

คอมมิท 12-4

Added nextPath() to the controller and use it to step thr…

ประสบการณ์ของผู้ใช้ที่ดีขึ้น

การแบ่งหน้าของเราก็ใช้งานได้ดีแล้ว แต่มันดูแปลกๆอยู่นิดหน่อย ตอนที่เราคลิ๊ก “โหลดเพิ่ม” และตัวจัดการเส้นทางกำลังโหลดข้อมูลเพิ่ม ตัวฟังก์ชัน waitOn ของ Iron Router จะส่งเราไปที่เทมเพลท loading ตอนที่เรากำลังรอข้อมูลใหม่อยู่ ผลลัพธ์คือ เราถูกส่งกลับไปที่ด้านบนของหน้า และต้องเลื่อนหน้าจอกลับลงมาตรงที่เรากำลังดูอยู่ทุกครั้ง

ดังนั้นในขั้นแรก เราจะบอก Iron Router ว่าไม่ต้อง waitOn การบอกรับข้อมูลอีกแล้ว โดยเราจะสร้างการบอกรับข้อมูล ที่ฮุคของ subscriptions แทน

สังเกตุด้วยว่า เราไม่ได้ คืนค่า จากการบอกรับข้อมูลในฮุค เพราะว่า การคืนค่า (ซึ่งส่วนมากเราจะใช้ฮุคของ subscriptions กันแบบนี้) จะกระตุ้นให้ฮุคของการโหลดทำงาน ซึ่งเราไม่ต้องการให้มันเกิดขึ้นตั้งแต่แรกแล้ว โดยเราจะใช้งานฮุคของ subscriptions เป็นที่สำหรับสร้างการบอกรับข้อมูลเท่านั้น คล้ายๆกับการใช้ฮุค onBeforeAction

นอกจากนี้เรายังคืนค่าตัวแปร ready ที่อ้างถึง this.postsSub.ready รวมมาในชุดข้อมูลด้วย โดยมันจะช่วยให้เราบอกเทมเพลทได้ว่า การบอกรับข่าวเรียบร้อยแล้วรึยัง

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  postsLimit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.postsLimit()};
  },
  subscriptions: function() {
    this.postsSub = Meteor.subscribe('posts', this.findOptions());
  },
  posts: function() {
    return Posts.find({}, this.findOptions());
  },
  data: function() {
    var hasMore = this.posts().count() === this.postsLimit();
    var nextPath = this.route.path({postsLimit: this.postsLimit() + this.increment});
    return {
      posts: this.posts(),
      ready: this.postsSub.ready,
      nextPath: hasMore ? nextPath : null
    };
  }
});

//...
lib/router.js

จากนั้นเราจะเช็คตัวแปร ready ในเทมเพลท เพื่อแสดง spinner ที่ด้านล่างของรายการข่าว ในขณะที่เรากำลังโหลดข่าวชุดใหม่อยู่

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{else}}
      {{#unless ready}}
        {{> spinner}}
      {{/unless}}
    {{/if}}
  </div>
</template>
client/templates/posts/posts_list.html

คอมมิท 12-5

Add a spinner to make pagination nicer.

เข้าถึงข่าวไหนก็ได้

ตอนนี้เรากำหนดให้โหลดข่าวใหม่ล่าสุดจำนวน 5 ข่าว ตามค่าตั้งต้น แต่จะเกิดอะไรขึ้นถ้ามีบางคนเปิดไปที่หน้าข่าวหน้าใดหน้าหนึ่งโดยตรงล่ะ

An empty template.
An empty template.

ถ้าคุณลองดู คุณก็จะพบข้อผิดพลาด not found ซึ่งดูมีเหตุผล เพราะเราบอกตัวจัดการเส้นทางให้บอกรับข้อมูล posts เมื่อโหลดเส้นทาง postsList เท่านั้น โดยไม่ได้บอกให้ทำอะไรกับเส้นทาง postPage เลย

มาถึงตรงนี้ ทั้งหมดที่เราทำก็คือ บอกรับข้อมูลกับข่าว n รายการล่าสุด คำถามคือ เราจะขอข่าวแค่รายการเดียวจากเซิร์ฟเวอร์อย่างไร เราจะบอกความลับเล็กๆให้คุณตรงนี้ว่า คุณสามารถทำการเผยแพร่ข้อมูลจากแต่ละคอลเลกชั่นได้มากกว่าหนึ่งตัว

ดังนั้นเราก็จะเรียกข่าวที่หายไปของเรากลับคืนมา โดยเราจะสร้างการเผยแพร่แบบ singlePost แยกออกมา ทำหน้าที่เผยแพร่ข่าวแค่ตัวเดียว ตามค่าของ _id

Meteor.publish('posts', function(options) {
  return Posts.find({}, options);
});

Meteor.publish('singlePost', function(id) {
  check(id, String)
  return Posts.find(id);
});

//...
server/publications.js

ตอนนี้ เราก็บอกรับข้อมูลข่าวในแบบที่เราต้องการในฝั่งไคลเอนต์ได้แล้ว โดยเราได้บอกรับข้อมูลของ coments ในฟังก์ชัน waitOn ที่เส้นทาง postPage ไว้แล้ว ดังนั้นเราก็เพิ่มแค่การบอกรับข้อมูลของ singlePost เข้าไปตรงนั้นด้วย แล้วก็อย่าลืมเพิ่มการบอกรับนี้เข้าไปที่เส้นทาง postEdit ด้วย เพราะว่ามันก็ใช้ข้อมูลเดียวกัน

//...

Router.route('/posts/:_id', {
  name: 'postPage',
  waitOn: function() {
    return [
      Meteor.subscribe('singlePost', this.params._id),
      Meteor.subscribe('comments', this.params._id)
    ];
  },
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/posts/:_id/edit', {
  name: 'postEdit',
  waitOn: function() { 
    return Meteor.subscribe('singlePost', this.params._id);
  },
  data: function() { return Posts.findOne(this.params._id); }
});

//...
lib/router.js

คอมมิท 12-6

Use a single post subscription to ensure that we can alwa…

และแล้วการแบ่งหน้าก็เสร็จเรียบร้อย แอพของเราก็ไม่มีปัญหาเรื่องขนาดอีกต่อไป และผู้ใช้ก็น่าจะเข้ามาป้อนลิงก์ข่าวได้มากขึ้นกว่าเดิม คุณว่ามันจะดีมั้ยถ้าเราจะหาวิธีจัดอันดับให้ข่าวพวกนี้ และคุณรู้อะไรมั้ย เรื่องนี้คือสิ่งที่เราเตรียมไว้ในบทต่อไป !

การโหวต

13

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

โดยเราอาจจะสร้างระบบจัดลำดับที่ซับซ้อนด้วย karma ที่เป็นระบบลดแต้มตามระยะเวลา และตัวอื่นๆ (ส่วนมากมีอยู่ใน Telescope พี่ใหญ่ของ Microscope) แต่กับแอพของเรา เราจะทำกันแบบง่ายๆ ให้สามารถจัดลำดับข่าวได้ตามจำนวนโหวตที่ได้รับก็พอ

เราจะเริ่มด้วยการหาทางให้ผู้ใช้ทำการโหวตข่าวได้

โมเดลข้อมูล

เราจะเก็บรายการผู้โหวตของแต่ละข่าว เพื่อที่เราจะรู้ว่าเมื่อใดควรแสดงปุ่มโหวตให้ผู้ใช้เห็น และในขณะเดียวกันก็ป้องกันการโหวตซ้ำได้ด้วย

ความเป็นส่วนตัวของข้อมูล และการเผยแพร่

เราจะเผยแพร่รายการผู้โหวตให้กับผู้ใช้ทุกคน ซึ่งทำให้ใครก็ได้เข้าถึงข้อมูลนี้ได้จากคอนโซลของเบราว์เซอร์โดยอัตโนมัติ

เรื่องนี้เป็นปัญหาเกี่ยวกับความเป็นส่วนตัวของข้อมูลที่เกิดขึ้นเนื่องจากวิธีการทำงานของคอลเลกชั่น ตัวอย่างเช่น เราต้องการให้ผู้ใช้รู้ว่า มีใครโหวตข่าวของเค้ากันบ้าง ในกรณีนี้ การเปิดเผยข้อมูลไม่น่าจะส่งผลอะไรตามมา แต่มันก็สำคัญที่อย่างน้อยเราก็รับรู้ปัญหานี้

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

// Fixture data
if (Posts.find().count() === 0) {
  var now = new Date().getTime();

  // create two users
  var tomId = Meteor.users.insert({
    profile: { name: 'Tom Coleman' }
  });
  var tom = Meteor.users.findOne(tomId);
  var sachaId = Meteor.users.insert({
    profile: { name: 'Sacha Greif' }
  });
  var sacha = Meteor.users.findOne(sachaId);

  var telescopeId = Posts.insert({
    title: 'Introducing Telescope',
    userId: sacha._id,
    author: sacha.profile.name,
    url: 'http://sachagreif.com/introducing-telescope/',
    submitted: new Date(now - 7 * 3600 * 1000),
    commentsCount: 2,
    upvoters: [], 
    votes: 0
  });

  Comments.insert({
    postId: telescopeId,
    userId: tom._id,
    author: tom.profile.name,
    submitted: new Date(now - 5 * 3600 * 1000),
    body: 'Interesting project Sacha, can I get involved?'
  });

  Comments.insert({
    postId: telescopeId,
    userId: sacha._id,
    author: sacha.profile.name,
    submitted: new Date(now - 3 * 3600 * 1000),
    body: 'You sure can Tom!'
  });

  Posts.insert({
    title: 'Meteor',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://meteor.com',
    submitted: new Date(now - 10 * 3600 * 1000),
    commentsCount: 0,
    upvoters: [], 
    votes: 0
  });

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: new Date(now - 12 * 3600 * 1000),
    commentsCount: 0,
    upvoters: [], 
    votes: 0
  });

  for (var i = 0; i < 10; i++) {
    Posts.insert({
      title: 'Test post #' + i,
      author: sacha.profile.name,
      userId: sacha._id,
      url: 'http://google.com/?q=test-' + i,
      submitted: new Date(now - i * 3600 * 1000 + 1),
      commentsCount: 0,
      upvoters: [], 
      votes: 0
    });
  }
}
server/fixtures.js

ก็เหมือนเคย ให้ปิดการทำงานของแอพ รัน meteor reset และสั่งให้แอพเริ่มทำงานใหม่ แล้วก็สร้างบัญชีผู้ใช้ใหม่ โดยต้องทำให้แน่ใจว่า คุณสมบัติสองตัวนี้ถูกกำหนดค่าเมื่อตอนสร้างข่าวด้วย

//...

var postWithSameLink = Posts.findOne({url: postAttributes.url});
if (postWithSameLink) {
  return {
    postExists: true,
    _id: postWithSameLink._id
  }
}

var user = Meteor.user();
var post = _.extend(postAttributes, {
  userId: user._id, 
  author: user.username, 
  submitted: new Date(),
  commentsCount: 0,
  upvoters: [], 
  votes: 0
});

var postId = Posts.insert(post);

return {
  _id: postId
};

//...
collections/posts.js

Voting Templates

แรกสุด เราจะเพิ่มปุ่มโหวตเข้าไปข้างหน้าชื่อข่าว และแสดงจำนวนโหวตที่ข้อมูลข่าว

<template name="postItem">
  <div class="post">
    <a href="#" class="upvote btn btn-default"></a>
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
      <p>
        {{votes}} Votes,
        submitted by {{author}},
        <a href="{{pathFor 'postPage'}}">{{commentsCount}} comments</a>
        {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
      </p>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn btn-default">Discuss</a>
  </div>
</template>
client/templates/posts/post_item.html
The upvote button
The upvote button

ต่อมาเราจะเรียกเมธอด upvote ที่เซิร์ฟเวอร์ เมื่อผู้ใช้คลิ๊กที่ปุ่ม

//...

Template.postItem.events({
  'click .upvote': function(e) {
    e.preventDefault();
    Meteor.call('upvote', this._id);
  }
});
client/views/posts/post_item.js

สุดท้ายเราจะกลับไปที่ไฟล์ lib/collections/posts.jsของเรา และเพิ่มเมธอดฝั่งเซิร์ฟเวอร์นี้เข้าไป เพื่อให้โหวตข่าวได้

//...

Meteor.methods({
  post: function(postAttributes) {
    //...
  },

  upvote: function(postId) {
    check(this.userId, String);
    check(postId, String);

    var post = Posts.findOne(postId);
    if (!post)
      throw new Meteor.Error('invalid', 'Post not found');

    if (_.include(post.upvoters, this.userId))
      throw new Meteor.Error('invalid', 'Already upvoted this post');

    Posts.update(post._id, {
      $addToSet: {upvoters: this.userId},
      $inc: {votes: 1}
    });
  }
});

//...
lib/collections/posts.js

คอมมิท 13-1

Added basic upvoting algorithm.

เมธอดนี้ทำงานแบบตรงไปตรงมา เริ่มจากเราทำการเช็คเพื่อให้แน่ใจว่า ผู้ใช้ล็อกอินเข้ามา และมีข่าวอยู่จริง จากนั้นเราก็เช็คซ้ำอีกทีว่าผู้ใช้ยังไม่ได้โหวตที่ข่าวนี้ และถ้าพวกเค้ายังไม่ได้ทำ เราจะเพิ่มจำนวนโหวต และใส่เค้าเข้าไปในรายชื่อผู้โหวต

ส่วนขั้นตอนสุดท้ายเป็นอะไรที่น่าสนใจ จากที่เราได้ใช้ตัวดำเนินการแบบพิเศษของ Mongo มาสองสามตัว ก็ยังมีอีกหลายตัวที่ต้องเรียนรู้ แต่ที่ใช้ประโยชน์ได้มากก็คือ $addToSet ที่ใช้เพิ่มค่าเข้าไปในอาร์เรย์ตราบใดที่มันยังไม่มีอยู่ในนั้น กับ $inc ที่ใช้เพิ่มค่าของฟิลด์จำนวนเต็ม

ปรับแต่งส่วนติดต่อผู้ใช้

ถ้าผู้ใช้ยังไม่ได้ล็อกอิน หรือเคยโหวตข่าวนี้มาแล้ว พวกเค้าก็ต้องโหวตไม่ได้ เพื่อแสดงให้รู้ที่ UI เราจะใช้ตัวช่วยทำการเพิ่มคลาส CSS ชื่อ disabled เข้าไปที่ปุ่มโหวตตามเงื่อนไข

<template name="postItem">
  <div class="post">
    <a href="#" class="upvote btn btn-default {{upvotedClass}}"></a>
    <div class="post-content">
      //...
  </div>
</template>
client/templates/posts/post_item.html
Template.postItem.helpers({
  ownPost: function() {
    //...
  },
  domain: function() {
    //...
  },
  upvotedClass: function() {
    var userId = Meteor.userId();
    if (userId && !_.include(this.upvoters, userId)) {
      return 'btn-primary upvotable';
    } else {
      return 'disabled';
    }
  }
});

Template.postItem.events({
  'click .upvotable': function(e) {
    e.preventDefault();
    Meteor.call('upvote', this._id);
  }
});
client/templates/posts/post_item.js

เราเพิ่งจะเปลี่ยนคลาสจาก .upvote เป็น .upvotable ดังนั้นต้องไม่ลืมที่จะเปลี่ยนมันที่ตัวจัดการเหตุการณ์ click ด้วย

Greying out upvote buttons.
Greying out upvote buttons.

คอมมิท 13-2

Grey out upvote link when not logged in / already voted.

ถัดมา คุณอาจจะสังเกตุว่า ข่าวที่มีโหวตเดียวจะแสดงเป็น “1 votes” ดังนั้นเราก็จะใช้เวลาตอนนี้จัดการเรื่องพหุพจน์ให้ถูกต้อง การทำให้เป็นพหุพจน์อาจเป็นเรื่องที่ซับซ้อนได้ แต่ตอนนี้เราแค่ทำแบบง่ายๆ โดยเราจะใช้ตัวช่วยของ Spacebars ที่สามารถนำมาใช้ตรงไหนก็ได้ ดังนี้

Template.registerHelper('pluralize', function(n, thing) {
  // fairly stupid pluralizer
  if (n === 1) {
    return '1 ' + thing;
  } else {
    return n + ' ' + thing + 's';
  }
});
client/helpers/spacebars.js

โดยตัวช่วยที่เราทำกันมาก่อนหน้านี้จะผูกกับเทมเพลทที่เกี่ยวข้องกันอยู่ แต่ถ้าเราใช้ Template.registerHelper จะเป็นการสร้างตัวช่วยแบบ global ที่สามารถเรียกใช้ได้จากทุกเทมเพลท

<template name="postItem">

//...

<p>
  {{pluralize votes "Vote"}},
  submitted by {{author}},
  <a href="{{pathFor 'postPage'}}">{{pluralize commentsCount "comment"}}</a>
  {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
</p>

//...

</template>
client/templates/posts/post_item.html
Perfecting Proper Pluralization (now say that 10 times)
Perfecting Proper Pluralization (now say that 10 times)

คอมมิท 13-3

Added pluralize helper to format text better.

ตอนนี้เราก็ควรเห็นเป็น “1 vote” แล้ว

อัลกอริทึมการโหวตที่ฉลาดขึ้น

โค้ดการโหวตของเราดูดีทีเดียว แต่เรายังทำให้ดีขึ้นได้อีก ในเมธอด upvote นั้น เราเรียก Mongo ไปสองครั้ง ครั้งแรกเพื่อดึงข่าว อีกครั้งเพื่ออัพเดทมัน

การทำแบบนี้จะทำให้เกิดปัญหาสองเรื่อง เรื่องแรก มันไม่ค่อยจะมีประสิทธิภาพเท่าไหร่ที่เรียกใช้ฐานข้อมูลถึงสองครั้ง แต่ที่สำคัญกว่านั้น มันทำให้เกิดกรณีที่แข่งขันกัน (race condition) เกิดขึ้น ซึ่งตอนนี้เรากำลังทำงานตามขั้นตอนของอัลกอริทึมแบบนี้

  1. ดึงข่าวจากฐานข้อมูล
  2. เช็คดูว่าผู้ใช้โหวตหรือยัง
  3. ถ้ายัง ให้ทำการโหวตด้วยชื่อผู้ใช้

แต่ถ้าเกิดผู้ใช้คนเดียวกันทำการโหวตอีกครั้งที่ข่าวเดิม ในขณะที่มีการทำงานอยู่ระหว่างขั้นตอนที่ 1 และ 3 ล่ะ จะเห็นว่าโค้ดที่เรากำลังใช้เปิดโอกาสให้ผู้ใช้สามารถโหวตที่ข่าวเดียวกันได้ถึงสองครั้ง แต่โชคดีที่ Mongo ยอมให้เราทำอะไรที่ฉลาดกว่านั้น ด้วยการรวมขั้นตอนที่ 1 ถึง 3 เข้าด้วยกันให้เป็นคำสั่งของ Mongo เพียงคำสั่งเดียว ดังนี้

//...

Meteor.methods({
  post: function(postAttributes) {
    //...
  },

  upvote: function(postId) {
    check(this.userId, String);
    check(postId, String);

    var affected = Posts.update({
      _id: postId, 
      upvoters: {$ne: this.userId}
    }, {
      $addToSet: {upvoters: this.userId},
      $inc: {votes: 1}
    });

    if (! affected)
      throw new Meteor.Error('invalid', "You weren't able to upvote that post");
  }
});

//...
collections/posts.js

คอมมิท 13-4

Better upvoting algorithm.

อธิบายง่ายๆได้ว่า “ให้หาข่าวด้วย id ที่ผู้ใช้คนนี้ยังไม่ได้โหวต และอัพเดทมันแบบนี้” ถ้าผู้ใช้ ยังไม่เคย โหวต มันก็จะพบข่าวที่มี id นั้น ในทำนองกลับกัน ถ้าผู้ใช้ ได้ โหวตแล้ว ผลที่ได้คือ ไม่พบข่าวเลย และก็ไม่มีอะไรเกิดขึ้นหลังจากนั้น

การชดเชยความล่าช้า

สมมุติว่าคุณพยายามจะโกงระบบ เพื่อส่งข่าวใดข่าวนึงของคุณขึ้นไปอยู่อันดับบนสุด ด้วยการปรับค่าจำนวนโหวตแบบนี้

> Posts.update(postId, {$set: {votes: 10000}});
Browser console

(เมื่อ postId คือ id ของข่าวคุณ)

การกระทำแบบไร้ยางอายที่จะหลอกระบบแบบนี้จะถูกจับได้ด้วยฟังก์ชัน callback แบบ deny() (ใน collections/posts.js จำกันได้มั้ย) และถูกปฏิเสธกลับไปทันที

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

มันเกิดอะไรขึ้นกันแน่ คำตอบคือ ในคอลเลกชั่น Posts ที่ไคลเอนต์ การ update ทำได้โดยไม่ติดอะไร และเกิดขึ้นทันที ทำให้ข่าวเด้งไปอยู่ที่ด้านบน ในขณะที่ฝั่งเซิร์ฟเวอร์ การ update จะถูกปฏิเสธ และต่อมาหลังจากนั้น (ไม่กี่มิลลิวินาที ถ้าคุณรัน Meteor ที่เครื่องคุณเอง) เซิร์ฟเวอร์ก็ส่งข้อผิดพลาดกลับมา และบอกให้คอลเลกชั่นที่ไคลเอนต์คืนค่ากลับตามเดิม

ผลที่เกิดขึ้นก็คือ ขณะที่รอการตอบสนองจากเซิร์ฟเวอร์อยู่นั้น ตัวหน้าจอเองไม่สามารถทำอะไรได้นอกจากใช้ข้อมูลจากคอลเลกชั่นที่โลคอลเท่านั้น หลังจากที่เซิร์ฟเวอร์ตอบกลับมาและปฏิเสธการเปลี่ยนแปลงนั้น หน้าจอถึงค่อยปรับตามอีกครั้ง

จัดอันดับข่าวในหน้าแรก

ตอนนี้เราก็มีคะแนนของข่าวแต่ละตัวจากจำนวนโหวตแล้ว เราจะมาแสดงรายการข่าวที่ดีที่สุดกัน การจะทำแบบนั้นได้ เราต้องดูว่า จะจัดการตัวบอกรับข้อมูลที่แยกเป็นสองตัวของคอลเลกชั่นข่าว และทำให้เทมเพลท postsList ดูดีขึ้น ได้อย่างไร

เริ่มต้นจากที่เราต้องการให้มีการบอกรับข้อมูล สอง ตัว แต่ละตัวสำหรับการจัดเรียงลำดับที่ต่างกัน เคล็ดลับของวิธีนี้ก็คือ ทั้งคู่จะบอกรับการเผยแพร่จากข้อมูล posts ตัวเดียวกัน จะต่างกันก็เพียงพารามิเตอร์ที่เรียกใช้เท่านั้น

และเราก็ต้องสร้างเส้นทางขึ้นใหม่สองตัวชื่อ newPosts และ bestPosts เข้าถึงได้จาก URL /new และ /best ตามลำดับ (แน่นอนว่า ต้องใช้ได้ทั้ง /new/5 และ /best/5 เมื่อมีการแบ่งหน้า )

โดยเราจะ ขยาย ตัวควบคุม PostsListController ของเราให้เป็นตัวควบคุมใหม่ที่แตกต่างกันคือ NewPostsListController และ BestPostsListControllerซึ่งทำให้เราสามารถใช้ตัวเลือกของเส้นทางที่เหมือนกันกับของ home และ newPosts ได้ใหม่อีกครั้ง โดยใช้วิธีสืบทอดจากตัวควบคุม PostsListController เพียงตัวเดียว และที่มากกว่านั้นคือ มันช่วยให้เราเห็นว่า Iron Router ยืดหยุ่นได้มากขนาดไหน

และเปลี่ยนค่าการจัดเรียงจากเดิม {submitted: -1} ใน PostsListController ให้เป็น this.sort ที่จะถูกส่งค่ามาจากตัวควบคุม NewPostsListController และ BestPostsListController อีกที

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  postsLimit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: this.sort, limit: this.postsLimit()};
  },
  subscriptions: function() {
    this.postsSub = Meteor.subscribe('posts', this.findOptions());
  },
  posts: function() {
    return Posts.find({}, this.findOptions());
  },
  data: function() {
    var hasMore = this.posts().count() === this.postsLimit();
    return {
      posts: this.posts(),
      ready: this.postsSub.ready,
      nextPath: hasMore ? this.nextPath() : null
    };
  }
});

NewPostsController = PostsListController.extend({
  sort: {submitted: -1, _id: -1},
  nextPath: function() {
    return Router.routes.newPosts.path({postsLimit: this.postsLimit() + this.increment})
  }
});

BestPostsController = PostsListController.extend({
  sort: {votes: -1, submitted: -1, _id: -1},
  nextPath: function() {
    return Router.routes.bestPosts.path({postsLimit: this.postsLimit() + this.increment})
  }
});

Router.route('/', {
  name: 'home',
  controller: NewPostsController
});

Router.route('/new/:postsLimit?', {name: 'newPosts'});

Router.route('/best/:postsLimit?', {name: 'bestPosts'});
lib/router.js

ให้สังเกตุว่าตอนนี้เรามีมากกว่าหนึ่งเส้นทางแล้ว และเราก็ถอดโค้ดของ nextPath ออกจาก PostsListController และใส่ลงไปใน NewPostsController และ BestPostsController เนื่องจากตอนนี้พาธจะแตกต่างกันทั้งสองกรณี

นอกจากนี้ เมื่อเราจัดเรียงผลตาม votes เราใช้วิธีการจัดเรียงตามวันที่ป้อนข่าว และตาม _id เพื่อให้แน่ใจว่า การจัดลำดับถูกต้องตามที่กำหนด

เมื่อตัวควบคุมของเราพร้อม เราก็สามารถลบเส้นทาง postsList ตัวก่อนออกไปได้อย่างปลอดภัย เพียงแค่ลบโค้ดต่อไปนี้ออกไป

 Router.route('/:postsLimit?', {
  name: 'postsList'
 })
lib/router.js

นอกจากนี้เรายังเพิ่มลิงก์เข้าไปที่ส่วนหัวด้วย

<template name="header">
  <nav class="navbar navbar-default" role="navigation">
    <div class="navbar-header">
      <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
        <span class="sr-only">Toggle navigation</span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </button>
      <a class="navbar-brand" href="{{pathFor 'home'}}">Microscope</a>
    </div>
    <div class="collapse navbar-collapse" id="navigation">
      <ul class="nav navbar-nav">
        <li>
          <a href="{{pathFor 'newPosts'}}">New</a>
        </li>
        <li>
          <a href="{{pathFor 'bestPosts'}}">Best</a>
        </li>
        {{#if currentUser}}
          <li>
            <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
          </li>
          <li class="dropdown">
            {{> notifications}}
          </li>
        {{/if}}
      </ul>
      <ul class="nav navbar-nav navbar-right">
        {{> loginButtons}}
      </ul>
    </div>
  </nav>
</template>
client/templates/includes/header.html

และสุดท้าย เราก็ต้องอัพเดทตัวจัดการเหตุการณ์ตอนลบข่าวด้วย

  'click .delete': function(e) {
    e.preventDefault();

    if (confirm("Delete this post?")) {
      var currentPostId = this._id;
      Posts.remove(currentPostId);
      Router.go('home');
    }
  }
client/templates/posts/posts_edit.js

หลังจากที่ทำมาทั้งหมด ตอนนี้เราก็จะได้รายการข่าวที่ดีที่สุดแบบนี้

Ranking by points
Ranking by points

คอมมิท 13-5

Added routes for post lists, and pages to display them.

ปรับส่วนหัวให้ดีขึ้น

ตอนนี้เราก็มีหน้าข่าวสองหน้าแล้ว และมันก็บอกยากว่าตอนนี้เรากำลังดูหน้าไหนอยู่ ดังนั้นเราก็จะมาปรับส่วนหัวเพื่อทำให้มันชัดเจนมากขึ้น โดยเราจะสร้างตัวจัดการ header.js และสร้างตัวช่วยที่ทำงานด้วยค่าพาธปัจจุบันกับเส้นทางแบบมีชื่อตั้งแต่หนึ่งตัวขึ้นไป มาช่วยกำหนดคลาสให้กับตัวนำทางของเรา

เหตุผลที่เราต้องรองรับเส้นทางแบบมีชื่อหลายตัวก็เพราะว่า ทั้งเส้นทาง home และ newPosts ของเรานั้น (ที่ตรงกับ URL / และ /new ตามลำดับ) ใช้เทมเพลทตัวเดียวกัน นั่นหมายความว่า ตัว activeRouteClass ของเรา ต้องฉลาดพอที่จะสร้างแท็ก <li> ที่แอคทีฟ ได้ทั้งสองกรณี

<template name="header">
  <nav class="navbar navbar-default" role="navigation">
    <div class="navbar-header">
      <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
        <span class="sr-only">Toggle navigation</span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </button>
      <a class="navbar-brand" href="{{pathFor 'home'}}">Microscope</a>
    </div>
    <div class="collapse navbar-collapse" id="navigation">
      <ul class="nav navbar-nav">
        <li class="{{activeRouteClass 'home' 'newPosts'}}">
          <a href="{{pathFor 'newPosts'}}">New</a>
        </li>
        <li class="{{activeRouteClass  'bestPosts'}}">
          <a href="{{pathFor 'bestPosts'}}">Best</a>
        </li>
        {{#if currentUser}}
          <li class="{{activeRouteClass 'postSubmit'}}">
            <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
          </li>
          <li class="dropdown">
            {{> notifications}}
          </li>
        {{/if}}
      </ul>
      <ul class="nav navbar-nav navbar-right">
        {{> loginButtons}}
      </ul>
    </div>
  </nav>
</template>
client/templates/includes/header.html
Template.header.helpers({
  activeRouteClass: function(/* route names */) {
    var args = Array.prototype.slice.call(arguments, 0);
    args.pop();

    var active = _.any(args, function(name) {
      return Router.current() && Router.current().route.getName() === name
    });

    return active && 'active';
  }
});
client/templates/includes/header.js
Showing the active page
Showing the active page

อาร์กิวเมนต์ของตัวช่วย

ที่ผ่านมาเรายังไม่เคยใช้ตัวช่วยในรูปแบบนี้กันเลย ซึ่งตัวช่วยเทมเพลทนั้นก็เหมือนกับแท็กของ Spacebars ตัวอื่นๆคือ สามารถรับอาร์กิวเมนต์ได้

โดยทั่วไปคุณสามารถส่งผ่านอาร์กิวเมนต์ตามชื่อที่กำหนดให้กับฟังก์ชันได้อยู่แล้ว และคุณก็ยังสามารถส่งค่าพารามิเตอร์แบบไม่มีชื่อที่ไม่จำกัดจำนวนได้อีกด้วย โดยเรียกใช้พวกมันได้จากอ็อบเจกต์ arguments ภายในฟังก์ชัน

ในกรณีหลังนั้น บางทีคุณอาจจำเป็นต้องแปลงอ็อบเจกต์ arguments ให้เป็นอาร์เรย์ของจาวาสคริปต์ แล้วค่อยดึงมันมาใช้ด้วย pop() เพื่อกำจัดค่าแฮช (hash) ที่เพิ่มเข้าไปในตอนท้ายโดย Spacebars

ในตัวนำทางแต่ละตัวนั้น ตัวช่วย activeRouteClass จะดึงรายชื่อเส้นทางไปใช้ และใช้ตัวช่วย any() ของ Underscore ตรวจดูว่ามีเส้นทางไหนที่ผ่านการทดสอบ (เช่น มี URL ตรงกับพาธปัจจุบัน)

ถ้ามีเส้นทางไหนตรงกับพาธปัจจุบัน any() จะคืนค่า true และสุดท้ายเราก็ได้ใช้ประโยชน์จากรูปแบบ boolean && string ของจาวาสคริปต์ ที่ false && myString ได้ค่า false แต่ true && myString ได้ค่าเป็น myString

คอมมิท 13-6

Added active classes to the header.

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

Publications ขั้นสูง

Sidebar 13.5

ถึงตอนนี้คุณน่าจะมีความเข้าใจการทำงานระหว่างการเผยแพร่และการบอกรับข้อมูลเป็นอย่างดีแล้ว ดังนั้นเราจะไม่มาฝึกฝนอะไรอีก แต่จะมาทำความเข้าใจวิธีการที่ก้าวหน้ามากขึ้นซักสองสามกรณี

เผยแพร่คอลเลกชั่นหลายครั้ง

ใน บทแทรกที่เกี่ยวกับการเผยแพร่ข้อมูล เราได้เห็นรูปแบบทั่วไปของการเผยแพร่และบอกรับข้อมูลกันมาแล้ว และเราก็รู้ว่าฟังก์ชัน _publishCursor ช่วยให้เราใช้งานมันได้ง่ายแค่ไหนกับไซต์ของเรา

ก่อนอื่นเรามาทบทวนว่า _publishCursor ทำอะไรให้เรา สิ่งที่มันทำก็คือ หาเอกสารทั้งหมดที่ตรงกับเคอร์เซอร์ที่ขอมา และส่งพวกมันกลับมาให้คอลเลกชั่นที่ฝั่งไคลเอนต์ ด้วยชื่อเดียวกัน สังเกตุว่า ชื่อของ การเผยแพร่ ไม่ได้ถูกนำมาใช้

นั่นก็หมายความว่า เราสามารถมีได้ มากกว่าหนึ่งการเผยแพร่ ที่เชื่อมระหว่างไคลเอนต์ กับ คอลเลกชั่นเวอร์ชันต่างๆของเซฺิร์ฟเวอร์

เรารู้จักรูปแบบนี้กันมาแล้วใน บทการแบ่งหน้า เมื่อเราเผยแพร่หน้าย่อยๆ ของข่าวทั้งหมดเพิ่มเติมจากข่าวที่กำลังแสดงอยู่

อีกกรณีที่มีการใช้งานคล้ายๆกัน คือ การเผยแพร่ ภาพรวม ของชุดเอกสารขนาดใหญ่ ควบคู่กับรายละเอียดทั้งหมดของเอกสารตัวเดียว

Publishing a collection twice
Publishing a collection twice
Meteor.publish('allPosts', function() {
  return Posts.find({}, {fields: {title: true, author: true}});
});

Meteor.publish('postDetail', function(postId) {
  return Posts.find(postId);
});

เมื่อไคลเอนต์บอกรับข้อมูลจากการเผยแพร่ทั้งสอง คอลเลกชั่น posts ของมันก็ถูกสร้างจากแหล่งข้อมูลสองตัวด้วย คือ รายการข่าวที่มีชื่อข่าวและชื่อผู้แต่ง ได้ข้อมูลจากการบอกรับตัวแรก กับข่าวที่มีรายละเอียดอีกหนึ่งข่าว ที่ได้ข้อมูลมาจากตัวที่สอง

คุณอาจจะมองออกว่า ข่าวที่ถูกเผยแพร่โดย postDetail ก็ถูกเผยแพร่โดย allPosts ด้วยเช่นกัน (แม้ว่ามันจะมีข้อมูลแค่บางส่วนเท่านั้น) ซึ่ง Meteor จะจัดการข้อมูลส่วนที่ทับซ้อนนี้ให้เรา โดยนำฟิลด์มารวมกัน และป้องกันไม่ให้มีข่าวซ้ำ

วิธีนี้เยี่ยมไปเลย เพราะในตอนที่เราสร้างรายการข่าวแบบสรุปนั้น เรากำลังใช้ข้อมูลจากอ็อบเจ็กต์ที่มีข้อมูลแค่เพียงพอให้เราใช้แสดงผลเท่านั้น ส่วนในตอนที่เราสร้างหน้าข่าวเดี่ยวๆนั้น เรามีข้อมูลทั้งหมดที่เราเอามาแสดงผลได้ และก็แน่นอนที่เราต้องคอยระวังว่า ข้อมูลที่ไคลเอนต์ไม่ได้มีฟิลด์ทั้งหมดติดมากับทุกข่าว ซึ่งในกรณีนี้อาจทำให้เกิดบั๊กได้ง่ายๆ

สิ่งที่ต้องจำไว้คือ ไม่มีข้อจำกัดว่า คุณต้องเผยแพร่ข้อมูลที่แตกต่างกันไปในแต่ละครั้ง คุณอาจเผยแพร่ข้อมูลชุดเดียวกันทั้งสองครั้งของการเผยแพร่ แต่จัดลำดับข้อมูลต่างกันก็ได้

Meteor.publish('newPosts', function(limit) {
  return Posts.find({}, {sort: {submitted: -1}, limit: limit});
});

Meteor.publish('bestPosts', function(limit) {
  return Posts.find({}, {sort: {votes: -1, submitted: -1}, limit: limit});
});
server/publications.js

เผยแพร่ตัวเดียว บอกรับหลายครั้ง

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

ใน Microscope เราบอกรับการเผยแพร่ posts หลายครั้ง แต่ Iron Router จะสร้างและลบการบอกรับแต่ละตัวให้เรา และก็ไม่มีเหตุผลว่า ทำไมเราถึงไม่สามารถบอกรับข้อมูลหลายๆครั้ง พร้อมๆกัน ได้

จากตัวอย่าง สมมุติว่าเราต้องการโหลดทั้งข่าวใหม่สุดและข่าวดีที่สุด มาไว้ที่หน่วยความจำในเวลาเดียวกัน

Subscribing twice to one publication
Subscribing twice to one publication

เราก็สร้างการเผยแพร่ขึ้นมาหนึ่งตัว

Meteor.publish('posts', function(options) {
  return Posts.find({}, options);
});

แล้วเราก็บอกรับการเผยแพร่นี้หลายครั้ง จริงๆแล้วก็ไม่แตกต่างจากที่เราทำใน Microscope

Meteor.subscribe('posts', {submitted: -1, limit: 10});
Meteor.subscribe('posts', {baseScore: -1, submitted: -1, limit: 10});

จริงๆแล้วมันเกิดอะไรขึ้นกันแน่ คำตอบคือ เบราว์เซอร์แต่ละตัวจะเปิดใช้ การบอกรับ สองตัว ที่แตกต่างกัน โดยแต่ละตัวเชื่อมต่อไปที่การเผยแพร่ ตัวเดียวกัน

การบอกรับแต่ละตัวส่งค่าอาร์กิวเมนท์ที่แตกต่างกันไปให้การเผยแพร่ ซึ่งโดยพื้นฐานแล้ว ในแต่ละครั้ง ชุดเอกสาร (ที่แตกต่างกัน) จะถูกดึงออกมาจากคอลเลกชั่น posts และส่งกลับมาที่คอลเลกชั่นฝั่งไคลเอนต์

นอกจากนี้ คุณยังสามารถบอกรับไปที่การเผยแพร่ตัวเดียวทั้งสองครั้งด้วย อาร์กิวเมนท์ชุดเดียวกันก็ได้ ! ซึ่งวิธีใช้แบบนั้นอาจจะหาวิธีทำให้เกิดประโยชน์ได้ยาก แต่ด้วยความยืดหยุ่นที่มี เราอาจมีโอกาสนำไปใช้ประโยชน์ได้ในวันหน้า !

คอลเลกชั่นหลายตัว บอกรับครั้งเดียว

สิ่งที่ไม่เหมือนกับฐานข้อมูลแบบ relational เช่น MySQL ที่ใช้การ joins เป็นหลัก ก็คือ ฐานข้อมูลแบบ NoSQL เช่น Mongo จะเกี่ยวข้องกับ denormalizing และ embedding เป็นสำคัญ เราจะมาดูกันว่า มันทำงานได้อย่างไรใน Meteor

ลองดูตัวอย่างที่ชัดเจนกันก่อน การที่เราเพิ่มข้อคิดเห็นเข้าไปที่ข่าว และเราก็พอใจกับการเผยแพร่เฉพาะข้อคิดเห็นของข่าวที่ผู้ใช้กำลังดูอยู่เท่านั้น

แต่ถ้าหากว่า เราต้องการจะแสดงข้อคิดเห็นของข่าว ทั้งหมด ในหน้าแรกขึ้นมาล่ะ (จำไว้ด้วยว่าข่าวที่แสดงจะเปลี่ยนไปเมื่อเราโหลดข่าวที่เหลือเพิ่มเข้ามาอีก ด้วยวิธีแบ่งหน้า) กรณีนี้ทำให้เรามีเหตุผลที่ดีที่จะฝังข้อคิดเห็นเข้าไปในข่าว และจริงๆแล้วมันคือสาเหตุหนึ่งที่เราทำ denormalize กับ จำนวน ข้อคิิดเห็นด้วย

อันที่จริงเราก็สามารถฝังข้อคิดเห็นเข้าไปในข่าว และกำจัดคอลเลกชั่น Comments ออกไปพร้อมๆกันได้อยู่แล้ว แต่ก็เหมือนกับที่เราเห็นในบท การ denormalize ที่การทำอย่างนั้น จะทำให้เราสูญเสียความสามารถพิเศษที่จะใช้จัดการกับคอลเลกชั่นที่แยกออกจากกัน

แต่ปรากฏว่า มันยังมีวิธีที่เกี่ยวข้องกับการบอกรับข้อมูล ที่ทำให้สามารถฝังข้อคิดเห็นของเรา และแยกคอลเลกชั่นออกจากกันได้ด้วย

สมมุติว่า ในหน้าแรกที่เราแสดงรายการข่าวนั้น เราต้องการให้แต่ละข่าว บอกรับข้อมูลข้อคิดเห็นสองตัวแรก ไปพร้อมๆกัน

มันค่อนข้างยากที่จะทำแบบนี้ได้ด้วยการใช้ข้อมูลจากการเผยแพร่ที่ไม่เกี่ยวข้องกัน โดยเฉพาะอย่างยิ่งเมื่อจำนวนข่าวถูกจำกัดไว้ด้วยเงื่อนไข (เช่น 10 ข่าวล่าสุด) ซึ่งทำให้เราต้องเขียนโค้ดการเผยแพร่คล้ายๆแบบนี้

Two collections in one subscription
Two collections in one subscription
Meteor.publish('topComments', function(topPostIds) {
  return Comments.find({postId: topPostIds});
});

ซึ่งจะทำให้เกิดปัญหาต่อประสิทธิภาพแน่ เพราะการเผยแพร่จะถูกลบทิ้งและสร้างใหม่ทุกครั้งที่ topPostIds เปลี่ยนไป

แต่ก็ยังมีวิธีแก้ โดยใช้ความจริงที่ว่า เราไม่เพียงแค่สามารถมี การเผยแพร่ มากกว่าหนึ่งตัว ต่อ คอลเลกชั่น แต่เรายังมีได้มากกว่าหนึ่ง คอลเลกชั่น ต่อ การเผยแพร่ ได้ด้วย

Meteor.publish('topPosts', function(limit) {
  var sub = this, commentHandles = [], postHandle = null;

  // send over the top two comments attached to a single post
  function publishPostComments(postId) {
    var commentsCursor = Comments.find({postId: postId}, {limit: 2});
    commentHandles[postId] = 
      Mongo.Collection._publishCursor(commentsCursor, sub, 'comments');
  }

  postHandle = Posts.find({}, {limit: limit}).observeChanges({
    added: function(id, post) {
      publishPostComments(id);
      sub.added('posts', id, post);
    },
    changed: function(id, fields) {
      sub.changed('posts', id, fields);
    },
    removed: function(id) {
      // stop observing changes on the post's comments
      commentHandles[id] && commentHandles[id].stop();
      // delete the post
      sub.removed('posts', id);
    }
  });

  sub.ready();

  // make sure we clean everything up (note `_publishCursor`
  //   does this for us with the comment observers)
  sub.onStop(function() { postHandle.stop(); });
});

จะเห็นว่าเราไม่ได้คืนค่าอะไรออกมาจากการเผยแพร่เลย เพราะเราส่งข้อความให้ sub ด้วยตัวเอง (ผ่าน .added() และเพื่อนๆ) ดังนั้นเราก็ไม่จำเป็นต้องใช้ _publishingCursor ทำงานให้เราโดยคืนค่าเคอร์เซอร์กลับมา

ตอนนี้ทุกๆครั้งที่เราเผยแพร่ข่าว เราก็เผยแพร่ข้อคิดเห็นสองตัวแรกติดไปกับมันด้วย โดยใช้การบอกรับแค่ครั้งเดียว !

ถึงแม้ว่า Meteor จะไม่ได้ทำให้วิธีนี้ใช้งานได้โดยตรง แต่คุณก็สามารถดูได้ที่แพ็คเกจ publish-with-relationsบนเว็บ Atmosphere ได้ ซึ่งแพ็คเกจนี้จะช่วยให้เราใช้งานในรูปแบบนี้ได้ง่ายขึ้น

เชื่อมโยงหลายคอลเลกชั่น

ด้วยความยืดหยุ่นของการบอกรับข้อมูลที่เราเพิ่งค้นพบนั้น คำตอบคือ ถ้าเราไม่ใช้ _publishCursor เราก็ไม่จำเป็นต้องทำตามข้อจำกัดที่ว่า คอลเลกชั่นต้นทางบนเซิร์ฟเวอร์จำเป็นต้องมีชื่อเดียวกับคอลเลกชั่นปลายทางที่ไคลเอนต์

One collection for two subscriptions
One collection for two subscriptions

เหตุผลหนึ่งที่ทำให้คุณต้องทำสิ่งนี้คือ การสืบทอดจากตารางเดียว (SIngle Table Inheritance)

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

เราสามารถที่จะเก็บอ็อบเจกต์เหล่านี้ลงในคอลเลกชั่น 'resources' ตัวเดียวได้ และใช้แอททริบิวต์ type เป็นตัวแยกแยะประเภทของมัน (ว่าเป็น video image link หรืออื่นๆ)

แม้เราจะมีแค่คอลเลกชั่น Resources ตัวเดียวบนเซิร์ฟเวอร์ เราก็สามารถแปลงคอลเลกชั่นตัวเดียวนั้นให้กลายเป็นคอลเลกชั่นของ Videos ของ Images และของตัวอื่นๆได้ ด้วยความพิเศษของโค้ดดังนี้

  Meteor.publish('videos', function() {
    var sub = this;

    var videosCursor = Resources.find({type: 'video'});
    Mongo.Collection._publishCursor(videosCursor, sub, 'videos');

    // _publishCursor doesn't call this for us in case we do this more than once.
    sub.ready();
  });

เราบอกให้ _publishCursor เผยแพร่วีดีโอของเรา (คืนค่ากลับมา) เหมือนที่ทำตามปกติ แต่แทนที่เราจะเผยแพร่ไปที่คอลเลกชั่น resources บนไคลเอนต์ เราก็จะเผยแพร่จาก 'resources' ไปที่ 'videos' แทน

อีกแนวคิดนึงที่คล้ายๆกันคือ ทำการเผยแพร่ไปที่คอลเลกชั่นฝั่งไคลเอนต์ โดย ไม่ต้องมีคอลเลกชั่นที่เซิร์ฟเวอร์เลย ! อย่างเช่น คุณอาจจะดึงข้อมูลจากบริการของบุคคลที่สาม แล้วเผยแพร่มันไปที่คอลเลกชั่นฝั่งไคลเอนต์

ก็ต้องขอบคุณความยืดหยุ่นของ API ของการเผยแพร่ ที่ทำให้ความเป็นไปได้ไม่มีที่สิ้นสุด

Animations

14

ตอนนี้เรามีทั้ง การโหวตแบบเรียลไทม์ การให้คะแนน และการจัดลำดับแล้ว แต่มันกลับทำให้เกิดประสบการณ์ใช้งานที่ไม่ค่อยดี ข่าวเด้งไปมาทั่วหน้าโฮมเพจ ดังนั้นเราจะมาแก้ปัญหานี้ด้วยการทำแอนิเมชั่นกัน

รู้จักกับ _uihooks

_uihooks ค่อนข้างใหม่ และเป็นคุณสมบัติที่ยังไม่ได้เปิดเผยของ Blaze โดยมีความหมายตามชื่อ คือ ใช้ดักจับเหตุการณ์ต่างๆที่เกิดขึ้นเมื่อส่วนประกอบต่างๆถูกเพิ่ม ลบ หรือเคลื่อนที่

โดยมีรายชื่อ ฮุค ดังต่อไปนี้

  • insertElement: ถูกเรียกใช้เมื่อ ส่วนประกอบใหม่ ถูกเพิ่มเข้าไป
  • moveElement:ถูกเรียกใช้เมื่อ ส่วนประกอบ ถูกเปลี่ยนตำแหน่ง
  • removeElement: ถูกเรียกใช้เมื่อ ส่วนประกอบ ถูกลบออก

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

Meteor และ DOM

ก่อนที่เราจะทำเรื่องสนุกๆ (เช่น เคลื่อนย้ายบางอย่าง) เราจำเป็นต้องเข้าใจวิธีที่ Meteor ทำงานกับ DOM (Document Object Model หรือ คอลเลกชั่นของส่วนประกอบ HTML ที่รวมกันเป็นหน้าเว็บ) เสียก่อน

จุดสำคัญที่ต้องจำไว้ก็คือ ส่วนประกอบใน DOM ไม่สามารถ ย้าย ได้จริง แต่พวกมันสามารถ ลบ และสร้างใหม่ได้ (ตรงนี้เป็นข้อจำกัดของ DOM เอง ไม่ใช่ของ Meteor) ดังนั้นถ้าต้องการให้เห็นว่า ส่วนประกอบ A สลับตำแหน่งกับ B สิ่งที่ Meteor ทำคือ ลบส่วนประกอบ B และแทรกสำเนาตัวใหม่ (B’) ไปไว้ข้างหน้า A

วิธีนี้ทำให้การสร้างแอนิเมชันดูยากนิดนึง เหตุผลก็เพราะว่า เราไม่สามารถทำให้ B เกิดการเคลื่อนที่โดยแค่ย้ายมันไปที่ตำแหน่งใหม่ได้ เนื่องจาก B อาจจะหายไปเมื่อถึงเวลาที่ Meteor สร้างหน้าเพจใหม่อีกครั้ง (ซึ่งเกิดขึ้นทันทีจากการทำงานแบบรีแอคทีฟ) แต่ไม่ต้องกังวลอะไรมาก เราจะหาทางออกได้

นักวิ่งโซเวียต

ก่อนอื่น มาฟังเรื่องเล่ากัน

เมื่อปี 1980 ปีที่สงครามเย็นเข้าครอบงำ กีฬาโอลิมปิคถูกจัดขึ้นที่เมืองมอสโคว และโซเวียตต้องการที่จะชนะการแข่งวิ่ง 100 เมตร โดยยอมทำทุกอย่างขอให้ชนะก็พอ นักวิทยาศาสตร์ระดับสุดยอดของโซเวียตก็ได้ทำการติดตั้งเครื่องเทเลพอร์ตให้กับนักกีฬาโซเวียตคนนึง เมื่อเสียงปืนดังขึ้น นักวิ่งคนนั้นก็หายไปจากจุดสตาร์ท และถูกส่งผ่านเข้าไปที่ห้วงรอยต่อของอวกาศและเวลาออกไปโผล่ที่เส้นชัยทันที

แต่โชคดีที่ กรรมการก็สังเกตุเห็นสิ่งผิดปรกติในทันทีเช่นกัน นักกีฬาคนนั้นจึงไม่มีทางเลือกต้องเทเลพอร์ตตัวเองกลับไปที่จุดสตาร์ท ก่อนที่จะได้รับอนุญาตให้เข้าแข่งอีกครั้ง โดยต้องวิ่งเหมือนกับคนอื่นๆ

แหล่งข่าวทางประวัติศาสตร์ของผมอาจไม่ค่อยน่าเชื่อถือนัก ดังนั้นคุณก็ไม่ต้องเชื่อเรื่องนี้ก็ได้ แต่ขอให้พยายามจำเรื่อง “นักวิ่งโซเวียตกับเครื่องเทเลพอร์ต” นี้ไว้ให้ขึ้นใจ เมื่อเราอ่านบทนี้กัน

แยกออกเป็นชิ้นๆ

เมื่อ Meteor ได้รับการอัพเดทและทำการแก้ไข DOM แบบรีแอคทีฟนั้น ข่าวของเราก็จะถูกเทเลพอร์ตไปที่ตำแหน่งสุดท้ายทันที เหมือนกับนั่งวิ่งโซเวียต แต่ไม่ว่าที่โอลิมปิค หรือในแอพเรา ก็ไม่มีเครื่องเทเลพอร์ตให้ใช้ ดังนั้นเราก็จะเทเลพอร์ตส่วนประกอบกลับไปที่ “จุดเริ่มต้น” และทำให้มัน “วิ่ง” (หรือที่เรียกว่า เคลื่อนที่ไป) จนถึงเส้นชัย

การที่จะสลับตำแหน่งข่าว A และ B (ในตำแหน่ง p1 และ p2 ตามลำดับ) เราก็ต้องทำตามขั้นตอนต่อไปนี้

  1. ลบ B
  2. สร้าง B’ ก่อน A ใน DOM
  3. เทเลพอร์ท B’ ไป p2
  4. เทเลพอร์ท A ไป p1
  5. เคลื่อนที่ A ไป p2
  6. เคลื่อนที่ B’ to p1

ภาพข้างล่างจะอธิบายขั้นตอนพวกนี้ได้แบบละเอียด

Switching two posts
Switching two posts

ย้ำอีกครั้ง ในขั้นตอนที่ 3 และ 4 นั้น เราไม่ได้ เคลื่อนที่ A และ B’ ไปที่ตำแหน่งของมัน แต่เรา “ทำการเทเลพอร์ต” พวกมันไปแทน เนื่องจากมันเกิดขึ้นทันที ทำให้เราเห็นเหมือนกับว่า B ไม่ได้ถูกลบออกไป และส่วนประกอบทั้งคู่ที่จะถูกเคลื่อนที่นั้น ก็ถูกวางไว้ที่ตำแหน่งใหม่ของมันอย่างถูกต้อง

โดยปกติ Meteor จะจัดการขั้นตอนที่ 1 และ 2 ให้ ซึ่งโค้ดตรงนี้เราเขียนเองได้ไม่ยาก ส่วนในขั้นตอนที่ 5 และ 6 ที่เราต้องทำคือ ย้ายมันไปยังตำแหน่งที่ถูกต้องของมัน ดังนั้นส่วนที่เราต้องคิดก็คือ ขั้นตอนที่ 3 และ 4 ที่ต้องส่งพวกมันไปที่จุดเริ่มต้นของการเคลื่อนที่

การจัดวางตำแหน่งของ CSS

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

ส่วนประกอบของหน้า ตามปกติแล้ว จะใช้การจัดวางตำแหน่งแบบ static ซึ่งการจัดวางแบบ static นี้ ตำแหน่งของส่วนประกอบจะถูกวางไปตามการไหลของข้อมูลในหน้านั้น โดยตำแหน่งในหน้าจอของพวกมันไม่สามารถเปลี่ยนแปลง หรือเคลื่อนที่ได้

การจัดวางตำแหน่งแบบ Relative จะกลับกันคือ ส่วนประกอบจะถูกวางตามการไหลของข้อมูล แต่สามารถเปลี่ยนตำแหน่ง ให้สัมพันธ์กับตำแหน่งเริ่มต้นได้

การจัดวางตำแหน่งแบบ Absolute จะไปอีกขั้น คือ ยอมให้คุณกำหนดตำแหน่งของส่วนประกอบด้วยพิกัด x/y ที่สัมพันธ์กับ document หรือ ส่วนประกอบตัวแม่ที่จัดวางตำแหน่งแบบ absolute หรือ relative

ในที่นี้เราจะใช้การจัดวางตำแหน่งแบบ relative กับการทำแอนิเมชั่นของข่าว ซึ่งเราได้เตรียม CSS ไว้ให้คุณแล้ว แต่ถ้าคุณต้องการทำด้วยตัวเอง ที่คุณต้องทำคือ เพิ่มโค้ดนี้เข้าไปในสไตล์ชีทของคุณ

.post{
  position:relative;
}
.post.animate{
  transition:all 300ms 0ms ease-in;
}
client/stylesheets/style.css

สังเกตุด้วยว่า เราทำแอนิเมชั่นกับข่าวที่มี CSS คลาสเป็น .animate เท่านั้น ทำให้เราสามารถที่จะควบคุมให้เกิดหรือไม่เกิดการแอนิเมชั่น ด้วยการเพิ่มหรือลบคลาสนี้

ตรงนี้ทำให้ขั้นตอนที่ 5 และ 6 ง่ายไปเลย ที่เราต้องทำทั้งหมดก็แค่ รีเซ็ท top ให้เป็น 0px (ค่าตั้งต้น) แล้วข่าวของเราก็จะกลับไปที่ตำแหน่ง “ปกติ” ของมัน

ดังนั้น งานที่ท้าทายของเราก็คือ หาตำแหน่ง ตั้งต้น (ตำแหน่ง 3 และ 4) ของการเคลื่อนที่ ซึ่งสัมพันธ์กับตำแหน่งใหม่ของพวกมัน พูดให้ง่ายขึ้นคือ ต้องเลื่อนพวกมันไปเท่าไหร่ ซึ่งก็ไม่ใช่เรื่องยากอะไรเหมือนกัน ระยะที่ต้องเลื่อนออกไป คิดง่ายๆก็จะเท่ากับ ตำแหน่งข่าวตอนแรก ลบด้วยตำแหน่งใหม่

ใช้งาน _uihooks

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

<template name="postsList">
  <div class="posts page">
    <div class="wrapper">
      {{#each posts}}
        {{> postItem}}
      {{/each}}
    </div>

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{else}}
      {{#unless ready}}
        {{> spinner}}
      {{/unless}}
    {{/if}}
  </div>
</template>
client/templates/posts/posts_list.html

ก่อนทำอย่างอื่นต่อ เราจะมาดูพฤติกรรมของรายการข่าว ตอนที่ ยังไม่มี แอนิเมชั่นกันก่อน

The non-animated post list.
The non-animated post list.

ตอนนี้เราจะนำ _uihooks มาใช้ โดยเราจะเลือกแท็ก div .wrapper จากข้างในฟังก์ชัน callback onRendered ของเทมเพลท และสร้างฮุค moveElement ดังนี้

Template.postsList.onRendered(function () {
  this.find('.wrapper')._uihooks = {
    moveElement: function (node, next) {
      // do nothing for now
    }
  }
});
client/templates/posts/posts_list.js

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

ว่าแล้วก็ลองทดสอบกันดู โดยเปิดไปที่หน้า “Best” และทำการโหวตข่าวสองสามตัว จะเห็นว่าลำดับของมันไม่เปลี่ยนแปลง จนกว่าคุณจะสั่งให้มันสร้างหน้าใหม่ (โดยการรีโหลด หรือเปลี่ยนเส้นทาง)

An empty moveElement callback: nothing happens
An empty moveElement callback: nothing happens

ตอนนี้เราก็แน่ใจแล้วว่า _uihooks ทำงานได้ ต่อจากนี้เราจะมาทำให้มันเคลื่อนที่กัน!

ทำแอนิเมชั่นให้การจัดลำดับข่าว

ฮุค moveElement รับค่าสองอาร์กิวเมนท์ node และ next

  • node เป็นส่วนประกอบตัวปัจจุบันที่กำลังเคลื่อนที่ไปยังตำแหน่งใหม่ใน DOM
  • next เป็นส่วนประกอบตัวที่อยู่ ถัดไป จากตำแหน่งใหม่ที่ node กำลังย้ายไป

จากข้อมูลตรงนี้ เราก็สามารถทำการสร้างแอนิเมชั่นได้ (ถ้าคุณต้องการทบทวนความจำ ก็สามารถกลับไปอ่านเรื่อง “นักวิ่งโซเวียต” ได้ตามสบาย) โดยเมื่อตรวจพบว่า มีการเปลี่ยนแปลงตำแหน่งใหม่เกิดขึ้น เราจะ

  1. เพิ่ม node ก่อน next (ซึ่งก็คือ การทำงานเดิมที่เกิดขึ้นก่อนที่เราจะกำหนดฮุค moveElement)
  2. ย้าย node กลับไปที่ตำแหน่งเดิม
  3. ดันส่วนประกอบทุกตัวที่อยู่ระหว่าง node และ next ออก เพื่อให้มีที่ว่างสำหรับ node
  4. เคลื่อนที่ ส่วนประกอบทั้งหมด กลับไปที่ตำแหน่งใหม่ของมัน

โดยเราจะทำตามขั้นตอนพวกนี้ด้วยความสามารถของ jQuery ไลบรารี่จัดการ DOM ที่ดีที่สุดแล้วในตอนนี้ ซึ่งวิธีการใช้งาน jQuery จะอยู่นอกเหนือขอบเขตของหนังสือเล่มนี้ แต่เราก็จะมาดูเมธอดของ jQuery ที่จะนำมาใช้กันแบบคร่าวๆ ดังนี้

  • $(): หุ้มส่วนประกอบของ DOM ด้วยเมธอด jQuery เพื่อทำให้เป็นอ็อบเจกต์แบบ jQuery
  • offset(): ดึงตำแหน่งปัจจุบันของส่วนประกอบ โดยสัมพันธ์กับ the document และคืนอ็อบเจ็กต์ที่มีค่า top และ left
  • outerHeight(): หาค่าความสูง “ภายนอก” (รวม padding และ margin ได้) ของส่วนประกอบ
  • nextUntil(selector): หาส่วนประกอบทั้งหมดที่อยู่ข้างหลังจนถึงตัวส่วนประกอบ (ไม่รวมเข้าไป) ที่ตรงกับ selector
  • insertBefore(selector): แทรกส่วนประกอบไปข้างหน้าตัวที่ตรงกับ selector
  • removeClass(class): ลบ CSS class class ที่มีอยู่ในส่วนประกอบ
  • css(propertyName, propertyValue): ตั้งค่าคุณสมบัติ _propertyName ให้มีค่าเป็น propertyValue
  • height(): หาความสูงของส่วนประกอบ
  • addClass(class): เพิ่ม CSS Class class เข้าไปที่ส่วนประกอบ
Template.postsList.onRendered(function () {
  this.find('.wrapper')._uihooks = {
    moveElement: function (node, next) {
      var $node = $(node), $next = $(next);
      var oldTop = $node.offset().top;
      var height = $node.outerHeight(true);

      // find all the elements between next and node
      var $inBetween = $next.nextUntil(node);
      if ($inBetween.length === 0)
        $inBetween = $node.nextUntil(next);

      // now put node in place
      $node.insertBefore(next);

      // measure new top
      var newTop = $node.offset().top;

      // move node *back* to where it was before
      $node
        .removeClass('animate')
        .css('top', oldTop - newTop);

      // push every other element down (or up) to put them back
      $inBetween
        .removeClass('animate')
        .css('top', oldTop < newTop ? height : -1 * height)


      // force a redraw
      $node.offset();

      // reset everything to 0, animated
      $node.addClass('animate').css('top', 0);
      $inBetween.addClass('animate').css('top', 0);
    }
  }
});
client/templates/posts/posts_list.js

คำอธิบายเพิ่มเติม

  • เราคำนวนความสูงของ $node เพื่อจะได้รู้ว่าจะต้องเลื่อนตัวส่วนประกอบ $inBetween ไปเท่าไหร่ และเราใช้ outerHeight(true) เพื่อใช้ทั้ง margin และ padding มาคำนวนด้วย
  • เราไม่รู้ว่า next มาก่อนหรือหลัง node เมื่อเราไล่ลงมาใน DOM เราก็เลยเช็คค่าทั้งสองตัว เมื่อเราสร้าง $inBetween
  • การสลับระหว่าง “เทเลพอร์ต” กับ “แอนิเมชั่น” เราใช้การปิดเปิด CSS คลาส animate (การเกิดแอนิเมชั่นถูกกำหนดในโค้ด CSS ของแอพแล้ว)
  • เนื่องจากเราใช้การจัดตำแหน่งแบบ relative เราก็ต้องรีเซ็ทค่า top ของส่วนประกอบให้เป็น 0 เพื่อนำมันกลับไปยังที่ที่มันควรจะอยู่

สั่งให้วาดใหม่

คุณอาจจะสงสัยเกี่ยวกับบรรทัด $node.offset ว่า ทำไมเราต้องหาตำแหน่งของ $node ถ้าเราไม่คิดจะทำอะไรกับมัน

ให้คุณคิดอย่างนี้ ถ้าคุณสั่งให้หุ่นยนตร์ที่ทำงานได้ตรงตามคำสั่งว่า ให้วิ่งไปทางเหนือ 5 กิโลเมตร แล้วเมื่อทำเสร็จให้กลับมาที่จุดเริ่มต้น มันอาจจะสรุปได้ว่า ตัวมันจะต้องกลับมาอยู่ที่เดิม และอาจทำการรักษาพลังงานโดยไม่วิ่งไปที่ไหนเลย

ดังนั้นการที่เราจะแน่ใจว่าหุ่นยนตร์ของเราจะวิ่งทั้งหมด 10 กิโลเมตรจริงๆ เราก็ต้องขอให้มันวัดพิกัดที่ตำแหน่ง 5 กม. ก่อนจะหันกลับมา

เบราว์เซอร์ก็ทำงานคล้ายๆกัน ถ้าเราใช้คำสั่ง css('top', oldTop - newTop) และ css('top', 0) ทั้งคู่พร้อมๆกัน พิกัดใหม่ก็จะไปแทนที่ตัวเดิมและไม่มีอะไรเกิดขึ้นอีก ดังนั้นถ้าเราต้องการเห็นแอนิเมชั่นจริงๆ เราต้องสั่งให้เบราว์เซอร์วาดภาพส่วนประกอบใหม่หลังจากที่เปลี่ยนตำแหน่งแรกไปแล้ว

และวิธีง่ายๆที่จะสั่งให้วาดใหม่ก็คือ สอบถามค่าค่า offset ของส่วนประกอบนั้น ซึ่งมันไม่มีทางรู้ว่าเป็นเท่าไหร่ จนกว่าจะวาดส่วนประกอบนั้นขึ้นใหม่อีกครั้ง

ลองเล่นมันดูหน่อย ให้กลับไปที่หน้า “Best” แล้วลองโหวตดู คุณควรเห็นข่าวของเราวิ่งขึ้นลงอย่างนุ่มนวลราวกับนักบัลเล่ต์

Animated reordering
Animated reordering

คอมมิท 14-1

Added post reordering animation.

ทำยังไงก็เฟดฉันไม่ได้

หลังจากที่เราจัดการเรื่องการจัดลำดับใหม่ไปแล้ว การทำแอนิเมชั่นที่ข่าวซึ่งกำลังถูกเพิ่มและลบก็เป็นเรื่องหมูๆ

ขั้นแรก เราจะทำให้ข่าวใหม่ค่อยๆปรากฏขึ้นมา (เพื่อความง่าย ครั้งนี้เราจะทำแอนิเมชั่นด้วยจาวาสคริปต์)

Template.postsList.onRendered(function () {
  this.find('.wrapper')._uihooks = {
    insertElement: function (node, next) {
      $(node)
        .hide()
        .insertBefore(next)
        .fadeIn();
    },
    moveElement: function (node, next) {
      //...
    }
  }
});
client/templates/posts/posts_list.js

เพื่อให้เห็นภาพชัดเจน เราจะทดสอบแอนิเมชั่นตัวใหม่โดยการเพิ่มข่าวผ่านคอนโซลดังนี้

Meteor.call('postInsert', {url: 'http://apple.com', title: 'Testing Animations'})
Fading in new posts
Fading in new posts

ต่อจากนั้นเราจะทำให้ข่าวที่ถูกลบค่อยๆเลือนหายไป

Template.postsList.onRendered(function () {
  this.find('.wrapper')._uihooks = {
    insertElement: function (node, next) {
      $(node)
        .hide()
        .insertBefore(next)
        .fadeIn();
    },
    moveElement: function (node, next) {
      //...
    },
    removeElement: function(node) {
      $(node).fadeOut(function() {
        $(this).remove();
      });
    }
  }
});
client/templates/posts/posts_list.js

ลองอีกครั้ง โดยลบข่าวจากคอนโซล (ใช้คำสั่ง Posts.remove('somePostId')) เพื่อดูผลที่เกิดขึ้น

Fading out deleted posts
Fading out deleted posts

คอมมิท 14-2

Fade items in when they are drawn.

การเปลี่ยนหน้า

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

การเปลี่ยนหน้าเป็นงานของ Iron Router เมื่อคุณคลิ๊กที่ลิงก์ ข้อมูลที่ตัวช่วย {{< yield}} ใน layout.html ก็จะถูกแทนที่

ซึ่งมันก็เหมือนกับที่เราเปลี่ยนพฤติกรรมของ Blaze ให้หน้าข่าวของเรา เราก็สามารถทำอย่างเดียวกันกับ {{> yield}} ได้ โดยเพิ่มการเปลี่ยนหน้าแบบเฟดเข้าไปในระหว่างเส้นทาง !

ถ้าเราต้องการทั้งเฟดเข้าและออก เราก็ต้องแสดงหน้าทั้งหมดให้ซ้อนทับกัน โดยใช้ position:absolute กับ div ตัวหุ้ม .page ที่หุ้มเทมเพลทของทุกหน้า

เราไม่ต้องการให้หน้าของเรามีตำแหน่งสัมพันธ์กับกรอบเบราว์เซอร์ (window) เพราะว่าข้อมูลจะไปทับกับหัวด้านบนได้ เราก็เลยให้ position:relative กับ div ตัวหุ้ม #main เพื่อทำให้ค่า position:absolute ของ div ตัวหุ้ม .page มีพิกัดเริ่มต้นจาก #main

เพื่อประหยัดเวลา เราก็เพิ่มโค้ด CSS ที่จำเป็นเข้าไปใน style.css ดังนี้

//...

#main{
  position: relative;
}
.page{
  position: absolute;
  top: 0px;
  width: 100%;
}

//...
client/stylesheets/style.css

ตอนนี้ก็ถึงเวลาลงโค้ดที่ใช้ทำให้หน้าเฟดกันได้แล้ว จะเห็นว่าโค้ดนี้ดูแล้วคุ้นๆตา เนื่องจากมันเหมือนกับตัวที่เราใช้ตอนเพิ่มและลบข่าว

Template.layout.onRendered(function() {
  this.find('#main')._uihooks = {
    insertElement: function(node, next) {
      $(node)
        .hide()
        .insertBefore(next)
        .fadeIn();
    },
    removeElement: function(node) {
      $(node).fadeOut(function() {
        $(this).remove();
      });
    }
  }
});
client/templates/application/layout.js
Transitioning in-between pages with a fade
Transitioning in-between pages with a fade

คอมมิท 14-3

Transition between pages by fading.

เราก็ได้เห็นวิธีการทำแอนิเมชั่นให้กับส่วนประกอบแอพ Meteor ของคุณกันแล้ว ถึงแม้มันจะไม่ได้มีรายละเอียดที่ครบถ้วนทั้งหมด เราก็หวังว่าคุณจะใช้มันเป็นพื้นฐานในการสร้างแอนิเมชั่นที่ซับซ้อนยิ่งขึ้นได้

Meteor Vocabulary

Sidebar 14.5

ไคลเอนต์ (Client)

เมื่อเราพูดถึงไคลเอนต์ เราหมายถึง โค้ดที่ทำงานใน เว็บเบราว์เซอร์ ของผู้ใช้งาน ไม่ว่าจะเป็นเบราว์เซอร์แบบธรรมดา เช่น ไฟร์ฟ็อกซ์ หรือ ซาฟารี หรือ บางอย่างที่ซับซ้อน เช่น UIWebView ในแอปพลิเคชั่นของ iPhone

คอลเลกชั่น (Collection)

คอลเลกชั่นของ Meteor คือ ที่จัดเก็บข้อมูลซึ่งทำการซิงโครไนซ์ระหว่างไคลเอนต์กับเซิร์ฟเวอร์โดยอัตโนมัติ คอลเลกชั่นมีชื่อ (เช่น ‘posts`) และโดยทั่วไปจะอยู่ทั้งบนไคลเอนต์และเซิร์ฟเวอร์ ถึงแม้ว่ามันจะทำงานแตกต่างกัน แต่พวกมันก็มี API ร่วมกัน โดยมีพื้นฐานมาจาก API ของ Mongo

ส่วนประมวลผล (Computation)

ส่วนประมวลผล คือ บล็อกของโค้ดที่ทำงานทุกครั้งเมื่อแหล่งข้อมูลแบบรีแอคทีฟที่มันเชื่อมโยงอยู่มีการเปลี่ยนแปลงอย่างน้อยหนึ่งตัว ซึ่งถ้าคุณมีแหล่งข้อมูลรีแอคทีฟ (เช่น ตัวแปรเซสชั่น) และต้องการให้ตอบสนองแบบรีแอคทีฟกับมัน คุณก็ต้องสร้างส่วนประมวลผลให้มัน

เคอร์เซอร์ (Cursor)

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

DDP

DDP คือ โปรโตคอลการกระจายข้อมูล (Distributed Data Protocol) ของ Meteor เป็นโปรโตคอลเพื่อใช้ซิงโครไนซ์คอลเลกชั่น และเรียกใช้เมธอด ซึ่ง DDP ถูกวางตัวให้เป็นโปรโตคอลพื้นฐาน ใช้ทำงานแทน HTTP กับแอปพลิเคชันแบบเรียลไทม์ที่มีการใช้ข้อมูลมาก

Tracker

Tracker คือ ระบบรีแอคทีฟของ Meteor ซึ่ง Tracker ถูกใช้งานอยู่เบื้องหลัง เพื่อทำให้ HTML มีความสอดคล้องกับโมเดลข้อมูลข้างหลังอย่างอัตโนมัติ

เอกสาร (Document)

Mongo คือ ฐานข้อมูลแบบเอกสาร (document-based) ทำให้อ็อบเจกต์ที่ออกมาจากคอลเลกชั่นถูกเรียกว่า “เอกสาร” ซึ่ง ก็คือ อ็อบเจกต์จาวาสคริปต์พื้นฐาน (แม้ว่ามันจะไม่สามารถมีฟังก์ชันข้างในได้ก็ตาม) ที่มีค่าคุณสมบัติพิเศษ _id ซึ่ง Meteor ใช้ติดตามคุณสมบัติต่างๆของมันทาง DDP

ตัวช่วย (Helpers)

เมื่อเทมเพลทต้องการสร้างอะไรบางอย่างที่ซับซ้อนกว่าค่าคุณสมบัติของเอกสาร มันจะเรียกใช้ ตัวช่วย ที่เป็นฟังก์ชันซึ่งช่วยในการสร้างข้อมูล

การชดเชยความล่าช้า (Latency Compensation)

คือ เทคนิคที่ยอมให้มีการจำลองการเรียกใช้เมธอดบนไคลเอนต์ เพื่อหลีกเลี่ยงการเกิดความล่าช้า ในขณะที่รอคอยการตอบสนองจากเซิร์ฟเวอร์

Meteor Development Group (MDG)

บริษัทที่พัฒนา Meteor โดยไม่เกี่ยวข้องกับเฟรมเวิร์คที่ใช้

เมธอด (Method)

เมธอดของ Meteor คือ การเรียกใช้กระบวนงานแบบระยะไกล (remote procedure call) จากไคลเอนต์ไปที่เซิร์ฟเวอร์ ด้วยการใช้ตรรกะพิเศษบางอย่าง เพื่อเก็บค่าคอลเลกชั่นที่เปลี่ยนไป และยอมให้มีการชดเชยความล่าช้า

MiniMongo

คอลเลกชั่นฝั่งไคลเอนต์ที่เก็บข้อมูลลงหน่วยความจำ จะมี API คล้ายๆของ Mongo ซึ่งไลบรารี่ที่รองรับการทำงานนี้ถูกเรียกว่า “MiniMongo” เพื่อแสดงถึงเวอร์ชันย่อมๆของ Mongo ที่ทำงานอยู่บนหน่วยความจำอย่างเดียว

แพ็คเกจ (Package)

แพ็คเกจของ Meteor จะประกอบด้วย โค้ดจาวาสคริปต์ที่ทำงานบนเซิร์ฟเวอร์ โค้ดจาวาสคริปต์ที่ทำงานบนไคลเอนต์ วิธีการดำเนินการกับทรัพยากร (เช่น SASS หรือ CSS) และทรัพยากรที่นำมาใช้งาน
แพ็คเกจจะเหมือนกับไลบราลี่แบบ super-powered โดย Meteor จะมาพร้อมชุดแพ็คเกจหลัก (core package) หลายตัว และยังหาเพิ่มได้จากที่ Atmosphere ซึ่งมีคอลเลกชั้นของแพ็คเกจที่พัฒนาโดยชุมชนผู้ใช้

การเผยแพร่ (Publication)

การเผยแพร่ คือ ขื่อของชุดข้อมูลที่ถูกปรับแต่งให้กับผู้ใช้งานแต่ละคนที่บอกรับข้อมูลกับมัน คุณสามารถสร้างการเผยแพร่ไว้บนเซิร์ฟเวอร์

เซิร์ฟเวอร์ (Server)

เซิร์ฟเวอร์ Meteor เป็น เซิร์ฟเวอร์แบบ HTTP และ DDP ทำงานผ่าน Node.js มันประกอบด้วยไลบรารี่ทั้งหมดของ Meteor และโค้ดจาวาสคริปต์ฝั่งเซิร์ฟเวอร์ของคุณ เมื่อคุณเริ่มการทำงานของเซิร์ฟเวอร์คุณ มันจะเชื่อมต่อไปยังฐานข้อมูล Mongo (ที่มันจะสั่งให้เริ่มทำงานโดยอัตโนมัติในตอนพัฒนาแอพ)

เซสชั่่น (Session)

เซสชั่นใน Meteor จะอ้างถึงแหล่งข้อมูลรีแอคทีฟที่ฝั่งไคลเอนต์ ซึ่งถูกใช้โดยแอปพลิเคชั่นของคุณ เพื่อติดตามสถานะที่ผู้ใช้ทำงานอยู่

การบอกรับ (Subscription)

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

เทมเพลท (Template)

เทมเพลท คือ วิธีการสร้าง HTML ใน จาวาสคริปต์ โดยปกติ Meteor รองรับ Spacebars ระบบเทมเพลทที่ไม่มีตรรกะ (logic-less) และมีแผนรองรับตัวอื่นๆมากขึ้นในอนาคต

ชุดข้อมูลย่อยในเทมเพลท (Template Data Context)

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