
การออกแบบสถาปัตยกรรมในส่วนของ Frontend เป็นเรื่องที่หลายๆคนมองข้ามไป ทั้งๆที่ Frontend มีบทบาทสำคัญมากในเว็บปัจจุบัน
สมัยก่อน เราอาจทนใช้เว็บที่โหลดช้าหรือใช้งานยากได้ เพราะไม่มีตัวเลือกมาก แต่ในปัจจุบัน คู่แข่งโผล่รายใหม่ๆขึ้นมาแทบทุกวัน ผู้ใช้เว็บมีตัวเลือกมากขึ้น
ส่วน Frontend ที่ผู้ใช้ต้องติดต่อด้วยตลอดเวลานั้นสามารถชี้เป็นชี้ตายให้กับธุรกิจได้เลย
ด้วยเหตุนี้ ความซับซ้อนของโค้ดเริ่มย้ายจากฝั่ง Backend มายังฝั่ง Frontend มากขึ้นเรื่อยๆ หาก Frontend Architecture ถูกออกแบบไว้ไม่ดี การจะขยับขยายเว็บหรือทีมจะทำได้ลำบากมาก
บทความนี้จะกล่าวยกตัวอย่างรูปแบบของสถาปัตยกรรม Frontend ของเว็บแอพพลิเคชั่นต่างๆ โดยจะเน้นถึงความเป็นมา และชี้ให้เห็นถึงข้อดีข้อเสียของรูปแบบต่างๆ
ผู้อ่านควรมีประสบการณ์ในการทำเว็บแอพพลิเคชั่นขนาดกลาง – ใหญ่ และมีความเข้าใจในเรื่อง Continuous integration เบื้องต้น
1. Server-generated frontend
ย้อนกลับไปยังสัก 15-20 ปีที่แล้ว เว็บต่างๆมีแค่ HTML กับ CSS เป็นหลัก เว็บจำนวนมากเป็นแบบ Static กล่าวคือ ผู้ใช้ได้แต่กดเข้าไปดูข้อมูลอย่างเดียว ไม่สามารถทำอะไรกับมันได้ (เช่นโพสต์ข้อความ คอมเม้นต์ ตอบกระทู้ กดไลค์)
ส่วนเว็บที่เป็นแบบ Dynamic การประมวลผลทั้งหมดจะต้องทำที่ฝั่งเซอร์เวอร์ ทุกครั้งที่เราพิมพ์คอมเม้นต์แล้วกดส่ง หน้าเว็บทั้งเว็บต้องถูกโหลดใหม่อีกครั้ง เพื่อให้คอมเม้นต์ปรากฏขึ้นมา ข้อมูลทุกอย่างต้องกลับไปที่เซอร์เวอร์ก่อน โดยเซอร์เวอร์จะสร้างหน้า HTML ใหม่แล้วส่งกลับมา
การรวมโค้ดทั้งส่วน Frontend และ Backend เข้าด้วย เป็นลักษณะเด่นของรูปแบบนี้ หากไปดูลึกเข้าไปในโค้ด จะพบว่ามีการรวม HTML ไว้กับ processing tag ต่างๆ (เช่น กรณี JSP, PHP, ASP) และมี if/else/for/while logic แทรกอยู่ยุ่บยั่บเต็มไปหมด
สถาปัตยกรรมรูปแบบเหล่านี้จะพบเห็นได้บ่อยมากในเว็บเก่าๆ และไม่ค่อยมีทีท่าว่าจะเปลี่ยน เพราะเปลี่ยนยากมาก
พอเวลาผ่านไป JavaScript และ AJAX จะถูกนำมาใช้ด้วย แต่แนวทางยังคงเหมือนเดิม คือใช้ ในการสร้าง HTML ขึ้นมา ถ้าคนในทีมไม่มีวินัยพอ อาจมีการสร้าง JavaScript global variable ด้วย processing tag ทำให้ทุกอย่างพันกันเละเทะไปหมด
เมื่อมีปัญหา นักพัฒนาเริ่มเรียนรู้และปรับตัวโดยใช้ MVC Framework ต่างๆเข้ามาช่วย และทำการแยกส่วน JavaScript กับ CSS ออกมาชัดเจน ทำให้การดูแลรักษาโค้ดทำได้ง่ายขึ้น
แต่แม้จะออกแบบดียังไงก็ตาม สถาปัตยกรรมรูปแบบนี้ก็มีขีดจำกัดของมันอยู่
- Backend กับ Frontend พัฒนาแยกจากกันไม่ได้ หากระหว่างพัฒนาอยู่ Backend พัง คนที่พัฒนา Frontend จะทำงานต่อไม่ได้ ต้องย้อนไป เวอร์ชั่นเก่าๆ แล้วสร้าง Branch พัฒนาแยกต่อไป ส่งผลให้มีโอกาสเกิด Long-live branch ซึ่งขัดกับแนวคิดของการทำ Continuous integration
- แม้นักพัฒนาจะพยายามแยกใช้ MVC Framework แยกส่วนต่างๆออกจากกัน แต่ด้วยความที่โค้ดของทั้งสองส่วนอยู่ใกล้กันมาก ทำให้เอื้อต่อการเอานำโค้ดมาปะปนกัน หากคนในทีมขาดวินัย หรือไม่มีกระบวนการ Code review ที่ดี โค้ดทั้งสองส่วนจะปนกันง่ายมาก
- ในบางเทคโนโลยี (เช่น Java ที่ deploy ด้วย WAR file) แม้เราจะแก้แค่ HTML หรือ JavaScript ไม่กี่บรรทัด เราต้อง Build ทั้งส่วน Frontend + Backend แล้ว Deploy ใหม่ทั้งหมด แทนที่จะทำการ Build แค่ในส่วนของ Frontend ทำให้การพัฒนาช้ากว่าที่ควรจะเป็น
ข้อจำกัดต่างๆเหล่านี้ ทำให้เกิดรูปแบบใหม่ขึ้นมา
2. Separated frontend — Monolith
จากรูปแบบที่แล้ว เราจะเห็นว่าให้รวมโค้ดของ Frontend กับ Backend เข้าด้วยเป็นเรื่องที่ไม่ค่อยดีเท่าไรนัก โค้ดจากแต่ละส่วนมักจะพันกันหมด จะมี interaction ทีก็ต้องโหลดหน้าเว็บใหม่ทั้งเว็บ
อย่ากระนั้นเลย เราแยก Frontend ออกมาเป็นเรื่องเป็นราวดีกว่า แยกมันออกมาใส่ใน Repository ต่างหากจาก Backend เลย
ส่วน Backend นั้นก็แปรสภาพเป็น REST API ให้ Frontend ดึงข้อมูลในรูปแบบของ XML/JSON แทน
มองในมุมของผู้ใช้ รูปดีแบบนี้ดีกว่า เพราะสามารถสร้างเว็บที่ตอบสนองผู้ใช้งานได้อย่างรวดเร็ว (ไม่ต้องโหลดเพจใหม่ทั้งหมด)
มองในมุมของผู้พัฒนา มีข้อดีอยู่มากเช่นกัน:
- หากทีมต้องการพัฒนา Mobile app ด้วย ก็สามารถให้แอพเรียกใช้ API จาก Backend ได้เลย
- การเขียน Unit test ง่ายขึ้น เพราะ Logic ทั้งสองฝั่งแยกกันชัดเจน
- เราสามารถ mock ฺBackend API เวลาเขียนโค้ดไม่ต้องพึ่งอีกส่วน หากโค้ด Backend พังขึ้นมาหรือกำลังปรับ API เราก็ทำงานต่อได้ด้วย Mock ของเรา
- การทดสอบ Edge case (เช่น เว็บคืนค่า error 404/500, คืน error code) ก็สามารถทำได้ง่ายกว่าด้วย Mock
- สามารถพัฒนาส่วนของ Frontend ได้เร็วกว่า เช่น
- ไม่ต้อง Build ส่วนของ Backend หากมีการแก้โค้ดแค่ใน Frontend
- ใช้ Grunt เพื่อทำการ watch + autoreload ในระหว่างการพัฒนา ไม่ต้องเสียเวลา redeploy หรือ สลับหน้าต่างกด F5
- รัน Unit test แค่ในส่วนของ Frontend อย่างเดียวได้ โดยใช้เวลาในการรันน้อยกว่า ทำให้รันบ่อยๆได้
รูปแบบนี้ดีมาก แต่สำหรับเว็บแอพพลิเคชั่นที่มีขนาดใหญ่ รูปแบบนี้จำเป็นต้องมีการแยก Module ให้ดี ไม่เช่นนั้นแล้ว โอกาสเกิด Conflict จะสูงมาก ทำให้ทำ Continuous integration ได้ลำบาก
ลองนึกภาพโค้ดทั้งหมดของ Frontend อยู่ใน repository เดียวและมีนักพัฒนาสัก 100 คนทำงานพร้อมกัน จะทำยังไงให้ Integration branch เสถียรได้เกือบตลอดเวลา เพราะถ้ามีใครพลาดขึ้นมา ทำ Build พัง ต้อง revert กลับทันที เพื่อไม่ให้กระทบคนอื่น
ในทางปฏิบัติ นี่เป็นเรื่องที่ยากพอสมควร ตัวอย่างเช่น อาจมีนักพัฒนาสามคน Commit ไล่เลี่ยกันในหนึ่ง Build window แล้ว Build ก็เกิดพังขึ้นมา เราจะรู้ได้ไงว่ามันพังที่ Commit ไหน (ทั้งสามคนอ้างว่า Run build local หลัง merge แล้วผ่านหมด) ถ้าสามคนอยู่ทีมเดียวกันก็คุยกันได้ง่าย แต่ถ้าไม่ แล้วแต่ละคนนั่งอยู่กันคนละประเทศล่ะ?
เมื่อ Integration build พังบ่อยๆ เราจะเริ่มเห็น Long-live branch โผล่ขึ้นมาจากแต่ละทีม พอใกล้ release ก็จะ Merge กันที Conflict กันกระจาย ไม่ได้หลับไม่ได้นอน
พอถึงจุดที่จะทำ Continuous delivery ก็จะมีปัญหาเรื่องการ promote ขึ้น environment ต่างๆ เพราะทุกส่วนต้องพร้อมหมด หากส่วนใดส่วนหนึ่งโยน JavaScript error ขึ้นมา โค้ดส่วนอื่นอาจจะพินาศไปด้วย
เพื่อลดปัญหานี้ให้น้อยที่สุด Frontend จะมักจะถูกแยกเป็น Module ย่อยๆ โดยนักพัฒนาก็จะแบ่งเป็นทีมย่อยๆที่ทำงานแยกจากกันได้โดยเกือบจะอิสระ โดยพยายามทำให้ Dependency ระหว่าง Module มีน้อยที่สุด
จะเห็นได้ว่า แม้รูปแบบนี้จะดีกว่าการทำทุกอย่างบน Backend แต่ก็ยังมีข้อจำกัดอยู่มาก โดยสองสิ่งที่สำคัญที่สุดสำหรับรูปแบบนี้คือ :
- การออกแบบ Module ต้องทำให้ดี และมี Dependency ต่อกันน้อย
- ทีมต้องมีวินัย รักษา Integration branch ให้เสถียรอยู่ตลอดเวลา
ที่เล่ามาทั้งหมดนี้ไม่ได้หมายความว่ารูปแบบนี้ไม่ดีนะครับ เพราะหากทำถูกต้อง
- เราจะได้โค้ดที่ทำ Continuous integration อยู่ตลอดเวลา
- โอกาสเกิด Library conflict (เช่น module นึงใช้ jQuery 2.1.x ส่วนอีกอันใช้ 1.9.x) จะน้อยมาก เพราะโค้ดทั้งหมดอยู่บน repository เดียวกัน ซึ่งแชร์ library ด้วยกัน
3. Separated frontend –Totally separated
วิธีแก้ข้อจำกัดของรูปแบบที่แล้ว คือการแยกทุก Module ขาดออกจากกัน นำมาซึ่งรูปแบบนี้
แบบนี้ Scale ได้แน่นอน แต่ละทีมมีอิสระอย่างเต็มที่ เพราะแต่ละ Module นั้นทำงานอยู่บน HTML page(s) ของตัวเอง อยากใช้ Library อันไหนก็จัดเต็มไปเลย ไม่ต้องห่วงว่าจะไปมี Conflict กับใคร
บางที่จะแยก Backend หรือแม้กระทั่ง Database ออกจากกันไปด้วยเลย แล้วทำเป็น Microservices แทนก็ได้ โดยให้ทีมเดียวกันดูแลตั้งแต่ Frontend Module ไปจนถึง Service (Backend API + Database)
หากเรามีโค้ดส่วนที่ทุกทีมต้องแชร์กัน ก็แยกออกมาเป็น Shared module(s) ซะ แล้วโหลดไปใช้งานด้วย Bower หรือ Dependency management tool อื่นๆ
แต่ไม่มีรูปแบบไหนที่สมบูรณ์แบบครับ วิธีนี้ก็ยังมีข้อจำกัดของมันอยู่
- การสื่อสารระหว่าง Frontend ข้าม Module นั้นลำบากกว่า
- เวลาข้าม Module จะต้องโหลด HTML/JavaScript ใหม่ ผู้ใช้จะรู้สึกได้ว่ามีรอยต่อระหว่างกันอยู่
- ต้องมีการแชร์ CSS และ Basic components (button, modal, navigation menu) ไม่อย่างนั้นหน้าตา UI ของแต่ละ module อาจแตกต่างกันมากจนทำให้ User experience เสีย
เรื่องของการสื่อสารข้าม Frontend module เป็นข้อจำกัดที่แก้ไขไม่ได้ เนื่องจากทั้งสอง Module นั้นทำงานบนคนละเพจกัน การส่งข้อมูลต้องส่งผ่าน URL parameters, query, POST หรือผ่านทาง ฺฺBackend แทน
ในเว็บแอพพลิเคชั่นบางประเภท โอกาสที่จะต้องใช้ข้อมูลจากสอง Module พร้อมกันอาจมีน้อย และไม่ต้องกังวลเรื่องหน้าตาของเว็บว่าจะต้องเหมือนกันเป๊ะ (ตัวอย่างเช่น Module หนึ่งเป็น Admin console, อีก Module เป็นของ Manager, และอีกส่วนของพนักงานคีย์ข้อมูล) การแยก Module ออกจากกันโดยสิ้นเชิงแบบนี้จะเหมาะมาก
ในอีกมุมหนึ่ง แอพพลิเคชั่นบางประเภท ทุกๆ Module จะต้องทำงานแบบเป็นเนื้อเดียวกัน เช่น
- Header, Footer, Menu ต้องเหมือนกันหมด
- การแสดง page loading indicator ต้องเหมือนกันหมด อยู่ในตำแหน่งเดียวกัน
- Language translation /locale setting ต้องสอดคล้องกัน
- หากเกิด Timeout ระหว่างการทำงาน จะต้องแสดงข้อความแบบเดียวกัน หรือ redirect ไปยัง Login page เหมือนกัน
การแยกแต่ละ Module ขาดออกจากกันและให้อิสระอย่างเต็มที่ มักจะทำให้แต่ละทีมสร้างโค้ดเพื่อจัดการกรณีเหล่านี้กันเอง แม้จะมี Shared module ให้เรียกใช้ แต่ทีมก็อาจจะเลือกสร้างทุกอย่างเอง เพราะจะได้ไม่ต้องรอทีมอื่นในการทำงาน ทำให้เกิด Duplication ได้ง่าย
4. Separated frontend — Portal and widgets
เพื่อแก้ข้อจำกัดของรูปแบบที่แล้ว เราสามารถสร้าง Portal module ซึ่งทำหน้าที่สร้างส่วนหลักๆของเว็บที่ทุก Module ต้องมีเหมือนกัน (Header, menu, error handling) แล้วทำการโหลด Module ย่อยๆเข้ามาแสดงผลตรงกลาง
บางที่อาจจะเรียกเจ้า Module ย่อยๆเหล่านี้ว่า Widget โดยที่แต่ละ Widget สามารถถูกพัฒนาโดยใครก็ได้ (อาจจะเป็น External party) ตราบเท่าที่ Widget เหล่านี้อยู่ในรูปแบบที่ Central module สามารถโหลดเข้ามาได้
รูปแบบนี้จะช่วยลดความซ้ำซ้อนของโค้ดระหว่าง Module ได้ โดยให้ Portal จัดการส่วนที่ซ้ำกัน (เช่น menu, header, loading indicator, notification box)
แต่รูปแบบนี้ก็มีความยากลำบากของมันเองอยู่
ความยากลำบากนี้คือการโหลด Widget อื่นๆเข้ามาแสดงนั่นเอง โดยตัว Portal ต้องมีข้อตกลงกับ Widget ว่าจะเรียกใช้งานโค้ดของอีกฝั่งอย่างไร ซึ่งแต่ละที่ก็อาจจะมีข้อตกลงไม่เหมือนกัน เช่น
4.1 ใช้ <iframe> ในการดึง Standalone HTML page มาแสดงผล
วิธีแรกคือการให้ Widget อื่นๆสร้าง Standalone HTML page ขึ้นมาให้ Central Module ดึงไปแสดงผลใน <iframe>
คำว่า Standalone นี้หมายความว่า HTML page นั้นๆจะต้องมี CSS, JavaScript libraries เป็นของตัวเอง
วิธีนี้จะเริ่มมีปัญหา หากมีหน้าใดหน้าหนึ่งที่ต้องการแสดง HTML จากหลายๆ Module มาแสดงผลร่วมกัน
- ช้ามากๆ เพราะทุก <iframe> ต้องโหลด library/css มา render ใหม่หมด ถ้าใช้ Angular ก็ต้องโหลดและ bootstrap ใหม่ทุกๆ <iframe>
บางคนอาจแย้งว่า cache หรือใช้ CDN เอาก็ได้ แต่เวลา render ใน browser ยังไงก็แก้ไขไม่ได้ - หากต้องมีการส่งข้อมูลระหว่าง Module (ข้าม <iframe>) จะทำได้ยาก
4.2 โหลด HTML มาแทรกลงใน <div> และโหลด JavaScript แล้ว bootstrap แยกต่างหาก
รูปแบบนี้มีวิธี Implement ที่ค่อนข้างหลากหลาย
ที่ผมเคยเห็นมีตั้งแต่โหลด HTML ด้วย xhr แล้วเอามา append ใน div เอง แล้วดึง JavaScript มาด้วย requireJS
แต่วิธีที่ผมเห็นแล้วเวิร์คมาก คือการใช้ AngularJS + ui-router ในการโหลด โดยโค้ดที่ใช้ในการใส่ ui-router กับ Controller จะแยกออกจากไปอยู่อีก repository นึง
สรุปแล้วรูปไหนดีล่ะ?
ผมขอตอบด้วยคำตอบคลาสสิคละกันครับว่า “It depends”
เอาเข้าจริง ไม่มีเว็บแอพพลิเคชั่นไหนไหนที่ออกแบบมาแล้วตรงตามที่ผมบอกเป๊ะๆหรอกครับ ส่วนใหญ่จะผสมๆกัน
ตัวอย่างเช่น แบ่งในระดับแรกเป็นแบบ Totally separated 5 modules ใหญ่ๆ แล้วให้อิสระในการออกแบบ Module ย่อยข้างใน โดย บาง Modules ก็เลือกที่จะทำ Server-generated frontend และบาง Modules ก็มาแนว Portal and widgets
หรืออีกกรณี ทีมทำเป็น Totally separated ในระดับของ Git repository แต่ใช้ Integration server ในการนำโค้ดของทุกๆ Module มา Build รวมกันและทำ Integration test แบบ Monolith
ดังนั้น หากคุณต้องอยู่ในสถานการณ์ที่ต้องออกแบบจริง แทนที่จะเลือกว่าใช้รูปแบบไหน ให้มองย้อนกลับไปยังปัจจัยเหล่านี้ แล้วสร้างรูปแบบที่เหมาะสมกับแอพพลิเคชั่นของคุณเอง
- User experience – เราต้องการให้ทุก Module เชื่อมต่อแบบเป็นเนื้อเดียวกันหมดหรือเปล่า ทำอย่างไรให้ Menu, Header และ UI ต่างๆ consistent โดยให้มี overhead น้อยที่สุด
- Scalability – เว็บแอพพลิเคชั่นตัวนี้จะใหญ่แค่ไหนในอนาคต สถาปัตยกรรมจะสามารถรองรับทีมจำนวนมากได้หรือเปล่า โครงสร้างของทีมในองค์กรเป็นอย่างไร เหมาะสมกับการแบ่ง module ออกจากกันหรือไม่
- Ease of continuous integration – จะรักษา Integration branch ให้เสถียรอยู่ตลอดเวลาได้หรือไม่ หากทำได้ยาก อาจจะต้องหั่น Frontend ออกเป็นแบบ Totally separated แทน
- Productivity – การแยก Frontend ออกจะทำให้พัฒนาได้เร็วขึ้น แต่คนในทีมไม่มีประสบการณ์ในการแยก Frontend ออกมา อาจจะทำให้ Productivity ตกลงมากในช่วงแรก เราจะยอมรับได้หรือไม่
- Communication between module – แต่ละ Module ต้องส่งข้อมูลหากันในระดับ Frontend มากแค่ไหน หากแยก Module ออกจากกันโดยสิ้นเชิง (Totally separated) จะส่งข้อมูลแบบไหนดี
- Mobile support – หากต้องการรองรับ Mobile application การใช้ Server-generated อาจไม่เหมาะนัก เพราะต้องสร้าง API ให้ Mobile ซ้ำอีกที หากมีการเปลี่ยนแปลงก็จะกระเทือนโค้ดทั้งสองส่วน
- Performance – การแยก Frontend ออกมาจะทำให้ Frontend code ทั้งหมดเป็น static resource ทำให้ latency น้อยกว่า
แน่นอนครับ ไม่รูปแบบใดที่ออกมาแล้วดีครบ 100% หมด ดังนั้น ผู้ออกแบบต้องคิดให้ละเอียด ว่าเรื่องใดที่สำคัญมาก สำคัญน้อย แล้วเลือก Trade-off ให้ดี
Chadchapol V.
ผู้เลือกผิดมาเยอะแล้ว