สวัสดี Habr!
บริษัทของเราเชี่ยวชาญในการพัฒนาโซลูชันซอฟต์แวร์ระดับ ERP ซึ่งส่วนใหญ่ถูกครอบครองโดยระบบธุรกรรมที่มีตรรกะทางธุรกิจจำนวนมากและการไหลของเอกสารและ EDMS ผลิตภัณฑ์ของเราในเวอร์ชันปัจจุบันใช้เทคโนโลยี JavaEE แต่เรากำลังทดลองไมโครเซอร์วิสอย่างจริงจังเช่นกัน หนึ่งในพื้นที่ที่มีปัญหามากที่สุดของการแก้ปัญหาดังกล่าวคือการบูรณาการระบบย่อยต่างๆ ที่เป็นของโดเมนที่อยู่ติดกัน ปัญหาการรวมระบบทำให้เราปวดหัวอย่างมากเสมอ ไม่ว่ารูปแบบสถาปัตยกรรม กลุ่มเทคโนโลยี และเฟรมเวิร์กที่เราใช้จะเป็นอย่างไรก็ตาม แต่เมื่อเร็วๆ นี้ มีความคืบหน้าในการแก้ไขปัญหาดังกล่าว
ในบทความที่ฉันแจ้งให้คุณทราบ ฉันจะพูดถึงประสบการณ์และการวิจัยทางสถาปัตยกรรมที่ NPO Krista มีในพื้นที่ที่กำหนด นอกจากนี้เรายังจะดูตัวอย่างวิธีแก้ปัญหาง่ายๆ สำหรับปัญหาการรวมจากมุมมองของนักพัฒนาแอปพลิเคชัน และค้นหาสิ่งที่ซ่อนอยู่เบื้องหลังความเรียบง่ายนี้
ข้อจำกัดความรับผิดชอบ
ฉันเสนอวิธีแก้ปัญหาทางสถาปัตยกรรมและทางเทคนิคที่อธิบายไว้ในบทความนี้โดยอาศัยประสบการณ์ส่วนตัวในบริบทของงานเฉพาะ โซลูชันเหล่านี้ไม่ได้อ้างว่าเป็นแบบสากลและอาจไม่เหมาะสมภายใต้เงื่อนไขการใช้งานอื่นๆ
BPM เกี่ยวอะไรกับเรื่องนี้?
เพื่อตอบคำถามนี้ เราต้องเจาะลึกลงไปอีกเล็กน้อยถึงปัญหาเฉพาะที่ประยุกต์ในแนวทางแก้ไขของเรา ส่วนหลักของตรรกะทางธุรกิจในระบบธุรกรรมทั่วไปของเราคือการป้อนข้อมูลลงในฐานข้อมูลผ่านอินเทอร์เฟซผู้ใช้ การตรวจสอบข้อมูลนี้ด้วยตนเองและแบบอัตโนมัติ ดำเนินการผ่านขั้นตอนการทำงานบางอย่าง การเผยแพร่ไปยังระบบอื่น / ฐานข้อมูลเชิงวิเคราะห์ / เก็บถาวร การสร้างรายงาน . ดังนั้นหน้าที่หลักของระบบสำหรับลูกค้าคือการทำให้กระบวนการทางธุรกิจภายในเป็นอัตโนมัติ
เพื่อความสะดวก เราใช้คำว่า "เอกสาร" ในการสื่อสารในฐานะที่เป็นนามธรรมของชุดข้อมูลที่รวมกันด้วยคีย์ทั่วไปซึ่งสามารถ "เชื่อมโยง" เวิร์กโฟลว์บางอย่างได้
แต่ตรรกะในการบูรณาการล่ะ? ท้ายที่สุดแล้ว งานบูรณาการถูกสร้างขึ้นโดยสถาปัตยกรรมของระบบซึ่ง "ตัด" ออกเป็นส่วน ๆ ที่ไม่ได้ตามคำขอของลูกค้า แต่อยู่ภายใต้อิทธิพลของปัจจัยที่แตกต่างกันโดยสิ้นเชิง:
- ขึ้นอยู่กับกฎหมายของคอนเวย์
- อันเป็นผลมาจากการนำระบบย่อยที่พัฒนาก่อนหน้านี้กลับมาใช้ซ้ำกับผลิตภัณฑ์อื่น
- ขึ้นอยู่กับดุลยพินิจของสถาปนิก ตามความต้องการที่ไม่เกี่ยวกับการใช้งาน
มีสิ่งล่อใจอย่างมากที่จะแยกตรรกะการรวมออกจากตรรกะทางธุรกิจของเวิร์กโฟลว์หลัก เพื่อที่จะไม่ทำให้ตรรกะทางธุรกิจเสียหายด้วยสิ่งประดิษฐ์การรวม และช่วยให้นักพัฒนาแอปพลิเคชันไม่ต้องเจาะลึกคุณลักษณะของภูมิทัศน์ทางสถาปัตยกรรมของระบบ วิธีนี้มีข้อดีหลายประการ แต่การฝึกฝนแสดงให้เห็นว่าไม่ได้ผล:
- การแก้ปัญหาการรวมมักจะกลับไปใช้ตัวเลือกที่ง่ายที่สุดในรูปแบบของการโทรแบบซิงโครนัสเนื่องจากจุดขยายที่จำกัดในการใช้งานเวิร์กโฟลว์หลัก (ข้อเสียของการรวมแบบซิงโครนัสจะกล่าวถึงด้านล่าง)
- สิ่งประดิษฐ์บูรณาการยังคงเจาะลึกตรรกะทางธุรกิจหลักเมื่อจำเป็นต้องมีข้อเสนอแนะจากระบบย่อยอื่น
- ผู้พัฒนาแอปพลิเคชันเพิกเฉยต่อการรวมระบบและสามารถทำลายมันได้อย่างง่ายดายโดยการเปลี่ยนเวิร์กโฟลว์
- จากมุมมองของผู้ใช้ ระบบจะยุติการเป็นหนึ่งเดียว "รอยต่อ" ระหว่างระบบย่อยจะสังเกตเห็นได้ชัดเจน และการดำเนินการของผู้ใช้ที่ซ้ำซ้อนปรากฏขึ้น เริ่มต้นการถ่ายโอนข้อมูลจากระบบย่อยหนึ่งไปยังอีกระบบหนึ่ง
อีกวิธีหนึ่งคือการพิจารณาการโต้ตอบแบบรวมเป็นส่วนหนึ่งของตรรกะทางธุรกิจหลักและขั้นตอนการทำงาน เพื่อป้องกันไม่ให้คุณสมบัติของนักพัฒนาแอปพลิเคชันพุ่งสูงขึ้น การสร้างการโต้ตอบการรวมใหม่ควรเป็นเรื่องง่ายและไม่ต้องใช้ความพยายาม โดยมีโอกาสเลือกโซลูชันน้อยที่สุด สิ่งนี้ทำได้ยากกว่าที่คิด: เครื่องมือจะต้องทรงพลังพอที่จะให้ทางเลือกที่หลากหลายแก่ผู้ใช้ในการใช้งาน โดยไม่ยอมให้เขา "ยิงเท้าตัวเอง" มีคำถามมากมายที่วิศวกรต้องตอบในบริบทของงานบูรณาการ แต่นักพัฒนาแอปพลิเคชันไม่ควรคำนึงถึงในงานประจำวันของเขา: ขอบเขตของธุรกรรม ความสม่ำเสมอ อะตอมมิกซิตี ความปลอดภัย การปรับขนาด โหลดและการกระจายทรัพยากร การกำหนดเส้นทาง การจัดกลุ่ม การกระจายและการสลับบริบท ฯลฯ จำเป็นต้องเสนอเทมเพลตโซลูชันที่ค่อนข้างง่ายให้กับนักพัฒนาแอปพลิเคชันซึ่งคำตอบสำหรับคำถามดังกล่าวทั้งหมดถูกซ่อนไว้แล้ว เทมเพลตเหล่านี้จะต้องค่อนข้างปลอดภัย: ตรรกะทางธุรกิจเปลี่ยนแปลงบ่อยมาก ซึ่งเพิ่มความเสี่ยงที่จะเกิดข้อผิดพลาด ต้นทุนของข้อผิดพลาดจะต้องอยู่ในระดับที่ค่อนข้างต่ำ
แต่ BPM เกี่ยวอะไรกับมัน? มีตัวเลือกมากมายสำหรับการนำเวิร์กโฟลว์ไปใช้...
อันที่จริงการนำกระบวนการทางธุรกิจไปใช้อีกอย่างหนึ่งนั้นได้รับความนิยมอย่างมากในโซลูชันของเรา - ผ่านคำจำกัดความที่ประกาศของแผนภาพการเปลี่ยนสถานะและการเชื่อมต่อของผู้จัดการกับตรรกะทางธุรกิจสำหรับการเปลี่ยน ในกรณีนี้ สถานะที่กำหนดตำแหน่งปัจจุบันของ "เอกสาร" ในกระบวนการทางธุรกิจคือคุณลักษณะของ "เอกสาร" เอง
นี่คือลักษณะของกระบวนการเมื่อเริ่มต้นโครงการ
ความนิยมของการนำไปใช้นี้เกิดจากความเรียบง่ายและความเร็วของการสร้างกระบวนการทางธุรกิจเชิงเส้น อย่างไรก็ตาม เนื่องจากระบบซอฟต์แวร์มีความซับซ้อนมากขึ้นอย่างต่อเนื่อง ส่วนอัตโนมัติของกระบวนการทางธุรกิจจึงเติบโตและซับซ้อนมากขึ้น มีความจำเป็นในการสลายตัว การนำส่วนต่างๆ ของกระบวนการกลับมาใช้ใหม่ รวมถึงกระบวนการแยกสาขาเพื่อให้แต่ละสาขาดำเนินการแบบขนาน ภายใต้เงื่อนไขดังกล่าว เครื่องมือจะไม่สะดวก และไดอะแกรมการเปลี่ยนสถานะจะสูญเสียเนื้อหาข้อมูล (การโต้ตอบการรวมระบบจะไม่สะท้อนให้เห็นในไดอะแกรมเลย)
นี่คือลักษณะของกระบวนการหลังจากการชี้แจงข้อกำหนดหลายครั้ง
ทางออกของสถานการณ์นี้คือการรวมเครื่องยนต์เข้าด้วยกัน
ส่วนเล็กๆ ของกระบวนการทางธุรกิจที่ซับซ้อน
ในระยะยาว โซลูชันไม่เป็นไปตามความคาดหวัง: ความเข้มข้นของแรงงานที่สูงในการสร้างกระบวนการทางธุรกิจผ่านเครื่องมือแบบภาพไม่อนุญาตให้บรรลุตัวบ่งชี้ประสิทธิภาพการผลิตที่ยอมรับได้ และเครื่องมือเองก็กลายเป็นหนึ่งในเครื่องมือที่นักพัฒนาไม่ชอบมากที่สุด นอกจากนี้ยังมีข้อร้องเรียนเกี่ยวกับโครงสร้างภายในของเครื่องยนต์ซึ่งนำไปสู่การปรากฏของ "แผ่นแปะ" และ "ไม้ค้ำยัน" จำนวนมาก
ด้านบวกที่สำคัญของการใช้ jBPM คือการตระหนักถึงประโยชน์และผลเสียของการมีสถานะคงอยู่ของอินสแตนซ์กระบวนการธุรกิจของตัวเอง นอกจากนี้เรายังเห็นความเป็นไปได้ของการใช้วิธีการแบบกระบวนการเพื่อใช้โปรโตคอลการรวมที่ซับซ้อนระหว่างแอปพลิเคชันต่างๆ โดยใช้การโต้ตอบแบบอะซิงโครนัสผ่านสัญญาณและข้อความ การมีอยู่ของสภาวะถาวรมีบทบาทสำคัญในเรื่องนี้
จากข้อมูลข้างต้น เราสามารถสรุปได้: แนวทางกระบวนการในรูปแบบ BPM ช่วยให้เราสามารถแก้ไขงานต่างๆ มากมายเพื่อทำให้กระบวนการทางธุรกิจที่ซับซ้อนมากขึ้นเป็นไปโดยอัตโนมัติ ผสมผสานกิจกรรมบูรณาการเข้ากับกระบวนการเหล่านี้ได้อย่างกลมกลืน และรักษาความสามารถในการแสดงกระบวนการที่นำไปใช้ด้วยสายตาในรูปแบบที่เหมาะสม
ข้อเสียของการโทรแบบซิงโครนัสเป็นรูปแบบการรวม
การรวมแบบซิงโครนัสหมายถึงการบล็อกการโทรที่ง่ายที่สุด ระบบย่อยหนึ่งทำหน้าที่เป็นฝั่งเซิร์ฟเวอร์และเปิดเผย API ด้วยวิธีที่จำเป็น ระบบย่อยอื่นทำหน้าที่เป็นฝั่งไคลเอ็นต์และทำการโทรและรอผลลัพธ์ในเวลาที่เหมาะสม ขึ้นอยู่กับสถาปัตยกรรมระบบ ฝั่งไคลเอ็นต์และเซิร์ฟเวอร์สามารถอยู่ในแอปพลิเคชันและกระบวนการเดียวกัน หรือในแอปพลิเคชันและกระบวนการที่ต่างกัน ในกรณีที่สอง คุณจะต้องใช้ RPC บางส่วนและจัดเตรียมพารามิเตอร์และผลลัพธ์ของการโทร
รูปแบบการรวมนี้มีข้อเสียค่อนข้างมาก แต่ในทางปฏิบัติก็ใช้กันอย่างแพร่หลายมากเนื่องจากความเรียบง่าย ความเร็วของการติดตั้งใช้งานดึงดูดใจและบังคับให้คุณใช้งานซ้ำแล้วซ้ำอีกเมื่อต้องเผชิญกับกำหนดเวลาที่เร่งด่วน โดยบันทึกโซลูชันว่าเป็นหนี้ทางเทคนิค แต่มันก็เกิดขึ้นเช่นกันที่นักพัฒนาที่ไม่มีประสบการณ์ใช้มันโดยไม่รู้ตัว เพียงแต่ไม่ตระหนักถึงผลเสียที่ตามมา
นอกเหนือจากการเพิ่มขึ้นที่ชัดเจนที่สุดในการเชื่อมต่อระบบย่อยแล้ว ยังมีปัญหาที่ชัดเจนน้อยลงเกี่ยวกับธุรกรรมที่ "เติบโต" และ "ขยายออกไป" แท้จริงแล้ว หากตรรกะทางธุรกิจทำการเปลี่ยนแปลง ธุรกรรมก็ไม่สามารถหลีกเลี่ยงได้ และในทางกลับกัน ธุรกรรมก็จะบล็อกทรัพยากรแอปพลิเคชันบางตัวที่ได้รับผลกระทบจากการเปลี่ยนแปลงเหล่านี้ นั่นคือจนกว่าระบบย่อยหนึ่งจะรอการตอบสนองจากอีกระบบหนึ่ง ระบบจะไม่สามารถทำธุรกรรมให้เสร็จสิ้นและถอดการล็อคออกได้ สิ่งนี้จะเพิ่มความเสี่ยงของผลกระทบหลายประการอย่างมาก:
- การตอบสนองของระบบหายไป ผู้ใช้รอเป็นเวลานานในการตอบคำถาม
- โดยทั่วไปเซิร์ฟเวอร์จะหยุดตอบสนองต่อคำขอของผู้ใช้เนื่องจากมีเธรดพูลหนาแน่นเกินไป: เธรดส่วนใหญ่ถูกล็อคบนทรัพยากรที่ถูกครอบครองโดยธุรกรรม
- การหยุดชะงักเริ่มปรากฏขึ้น: โอกาสที่จะเกิดขึ้นนั้นขึ้นอยู่กับระยะเวลาของธุรกรรม จำนวนตรรกะทางธุรกิจ และการล็อกที่เกี่ยวข้องกับธุรกรรม
- ข้อผิดพลาดการหมดเวลาของธุรกรรมปรากฏขึ้น
- เซิร์ฟเวอร์จะ “ล้มเหลว” ด้วย OutOfMemory หากงานนั้นต้องการการประมวลผลและการเปลี่ยนแปลงข้อมูลจำนวนมาก และการมีอยู่ของการรวมแบบซิงโครนัสทำให้ยากต่อการแยกการประมวลผลเป็นธุรกรรมที่ “เบากว่า”
จากมุมมองทางสถาปัตยกรรม การใช้การบล็อกการโทรระหว่างการรวมทำให้สูญเสียการควบคุมคุณภาพของระบบย่อยแต่ละระบบ: เป็นไปไม่ได้ที่จะรับประกันตัวบ่งชี้คุณภาพเป้าหมายของระบบย่อยหนึ่งโดยแยกจากตัวบ่งชี้คุณภาพของระบบย่อยอื่น หากระบบย่อยได้รับการพัฒนาโดยทีมที่แตกต่างกัน นี่ถือเป็นปัญหาใหญ่
สิ่งต่างๆ จะน่าสนใจยิ่งขึ้นหากระบบย่อยที่ถูกรวมอยู่ในแอปพลิเคชันที่แตกต่างกัน และคุณจำเป็นต้องทำการเปลี่ยนแปลงแบบซิงโครนัสทั้งสองด้าน จะมั่นใจในการทำธุรกรรมของการเปลี่ยนแปลงเหล่านี้ได้อย่างไร?
หากมีการเปลี่ยนแปลงในธุรกรรมที่แยกจากกัน คุณจะต้องจัดเตรียมการจัดการข้อยกเว้นและการชดเชยที่เชื่อถือได้ และสิ่งนี้จะขจัดประโยชน์หลักของการรวมระบบแบบซิงโครนัสโดยสิ้นเชิง นั่นก็คือความเรียบง่าย
ธุรกรรมแบบกระจายก็คำนึงถึงเช่นกัน แต่เราไม่ได้ใช้ธุรกรรมเหล่านี้ในโซลูชันของเรา: เป็นการยากที่จะรับรองความน่าเชื่อถือ
“Saga” ทางออกของปัญหาการทำธุรกรรม
ด้วยความนิยมที่เพิ่มขึ้นของไมโครเซอร์วิส จึงมีความต้องการ
รูปแบบนี้แก้ปัญหาที่กล่าวมาข้างต้นของการทำธุรกรรมที่ยาวนานได้อย่างสมบูรณ์แบบ และยังขยายความสามารถในการจัดการสถานะของระบบจากด้านข้างของตรรกะทางธุรกิจ: การชดเชยหลังจากธุรกรรมที่ล้มเหลวอาจไม่ทำให้ระบบกลับสู่สถานะเดิม แต่ให้ เส้นทางการประมวลผลข้อมูลทางเลือก นอกจากนี้ยังช่วยให้คุณหลีกเลี่ยงการทำซ้ำขั้นตอนการประมวลผลข้อมูลที่เสร็จสมบูรณ์เมื่อพยายามทำให้กระบวนการจบลงที่ "ดี"
สิ่งที่น่าสนใจคือ ในระบบเสาหิน รูปแบบนี้ยังมีความเกี่ยวข้องเมื่อกล่าวถึงการรวมระบบย่อยที่เชื่อมต่อกันอย่างหลวมๆ และผลกระทบด้านลบที่เกิดจากธุรกรรมที่ใช้เวลานานและการล็อคทรัพยากรที่เกี่ยวข้อง
ในส่วนที่เกี่ยวข้องกับกระบวนการทางธุรกิจของเราในรูปแบบ BPM ปรากฏว่าการนำ "Saga" ไปใช้นั้นง่ายมาก: แต่ละขั้นตอนของ "Saga" สามารถระบุเป็นกิจกรรมภายในกระบวนการทางธุรกิจได้ และสถานะที่คงอยู่ของกระบวนการทางธุรกิจด้วย กำหนดสถานะภายในของ "Saga" นั่นคือเราไม่จำเป็นต้องมีกลไกการประสานงานเพิ่มเติม สิ่งที่คุณต้องมีคือนายหน้าข้อความที่รองรับการรับประกัน "อย่างน้อยหนึ่งครั้ง" เพื่อเป็นพาหนะ
แต่โซลูชันนี้มี "ราคา" ของตัวเองด้วย:
- ตรรกะทางธุรกิจมีความซับซ้อนมากขึ้น: จำเป็นต้องมีการชดเชย
- จำเป็นต้องละทิ้งความสอดคล้องอย่างสมบูรณ์ซึ่งอาจมีความอ่อนไหวเป็นพิเศษสำหรับระบบเสาหิน
- สถาปัตยกรรมจะซับซ้อนขึ้นเล็กน้อย และความต้องการเพิ่มเติมสำหรับนายหน้าข้อความก็ปรากฏขึ้น
- จำเป็นต้องมีเครื่องมือตรวจสอบและดูแลระบบเพิ่มเติม (แม้ว่าโดยทั่วไปแล้วจะดี: คุณภาพของบริการระบบจะเพิ่มขึ้น)
สำหรับระบบเสาหิน เหตุผลในการใช้ "Sag" ยังไม่ชัดเจนนัก สำหรับไมโครเซอร์วิสและ SOA อื่นๆ ซึ่งส่วนใหญ่แล้วจะมีนายหน้าอยู่แล้ว และสูญเสียความสม่ำเสมออย่างสมบูรณ์เมื่อเริ่มโครงการ ประโยชน์ของการใช้รูปแบบนี้อาจมีมากกว่าข้อเสียอย่างมาก โดยเฉพาะอย่างยิ่งหากมี API ที่สะดวกสบายในตรรกะทางธุรกิจ ระดับ.
การห่อหุ้มตรรกะทางธุรกิจในไมโครเซอร์วิส
เมื่อเราเริ่มทดลองใช้ไมโครเซอร์วิส มีคำถามที่สมเหตุสมผลเกิดขึ้น: จะวางตรรกะทางธุรกิจโดเมนไว้ที่ใดที่เกี่ยวข้องกับบริการที่รับประกันความคงอยู่ของข้อมูลโดเมน
เมื่อดูสถาปัตยกรรมของ BPMS ต่างๆ อาจดูสมเหตุสมผลที่จะแยกตรรกะทางธุรกิจออกจากความคงอยู่: สร้างเลเยอร์ของแพลตฟอร์มและไมโครเซอร์วิสที่ไม่ขึ้นกับโดเมนซึ่งสร้างสภาพแวดล้อมและคอนเทนเนอร์สำหรับการดำเนินการตรรกะทางธุรกิจของโดเมน และออกแบบการคงอยู่ของข้อมูลโดเมนเป็น เลเยอร์ไมโครเซอร์วิสที่เรียบง่ายและมีน้ำหนักเบาที่แยกจากกัน กระบวนการทางธุรกิจในกรณีนี้จะทำหน้าที่ประสานการบริการของเลเยอร์การคงอยู่
วิธีการนี้มีข้อได้เปรียบที่ยิ่งใหญ่มาก: คุณสามารถเพิ่มฟังก์ชันการทำงานของแพลตฟอร์มได้มากเท่าที่คุณต้องการ และมีเพียงไมโครเซอร์วิสแพลตฟอร์มที่เกี่ยวข้องเท่านั้นที่จะ "อ้วน" จากสิ่งนี้ กระบวนการทางธุรกิจจากโดเมนใดๆ จะสามารถใช้ฟังก์ชันการทำงานใหม่ของแพลตฟอร์มได้ทันทีที่มีการอัปเดต
การศึกษาโดยละเอียดเพิ่มเติมเผยให้เห็นข้อเสียที่สำคัญของแนวทางนี้:
- บริการแพลตฟอร์มที่ดำเนินการตรรกะทางธุรกิจของหลายโดเมนพร้อมกันนั้นมีความเสี่ยงสูงเนื่องจากจุดล้มเหลวเพียงจุดเดียว การเปลี่ยนแปลงตรรกะทางธุรกิจบ่อยครั้งจะเพิ่มความเสี่ยงของข้อผิดพลาดที่นำไปสู่ความล้มเหลวทั่วทั้งระบบ
- ปัญหาด้านประสิทธิภาพ: ตรรกะทางธุรกิจทำงานกับข้อมูลผ่านอินเทอร์เฟซที่แคบและช้า:
- ข้อมูลจะถูกจัดเรียงและสูบผ่านสแต็กเครือข่ายอีกครั้ง
- บริการโดเมนมักจะให้ข้อมูลมากกว่าที่จำเป็นสำหรับตรรกะทางธุรกิจในการประมวลผลเนื่องจากความสามารถไม่เพียงพอสำหรับการกำหนดพารามิเตอร์คำขอที่ระดับ API ภายนอกของบริการ
- ตรรกะทางธุรกิจหลายชิ้นที่เป็นอิสระสามารถขอข้อมูลเดียวกันเพื่อประมวลผลซ้ำได้ (ปัญหานี้สามารถบรรเทาได้ด้วยการเพิ่มส่วนประกอบเซสชันที่แคชข้อมูล แต่สิ่งนี้จะทำให้สถาปัตยกรรมซับซ้อนยิ่งขึ้นและสร้างปัญหาเกี่ยวกับความเกี่ยวข้องของข้อมูลและแคชเป็นโมฆะ)
- ปัญหาการทำธุรกรรม:
- กระบวนการทางธุรกิจที่มีสถานะคงอยู่ซึ่งจัดเก็บโดยบริการแพลตฟอร์มไม่สอดคล้องกับข้อมูลโดเมน และไม่มีวิธีง่ายๆ ในการแก้ปัญหานี้
- การบล็อกข้อมูลโดเมนนอกธุรกรรม: หากตรรกะทางธุรกิจของโดเมนจำเป็นต้องทำการเปลี่ยนแปลงหลังจากตรวจสอบความถูกต้องของข้อมูลปัจจุบันในครั้งแรก จำเป็นต้องยกเว้นความเป็นไปได้ของการเปลี่ยนแปลงทางการแข่งขันในข้อมูลที่ประมวลผล การบล็อกข้อมูลภายนอกสามารถช่วยแก้ปัญหาได้ แต่วิธีแก้ปัญหาดังกล่าวมีความเสี่ยงเพิ่มเติมและลดความน่าเชื่อถือโดยรวมของระบบ
- ปัญหาเพิ่มเติมเมื่ออัปเดต: ในบางกรณี บริการคงอยู่และตรรกะทางธุรกิจจำเป็นต้องได้รับการอัปเดตพร้อมกันหรือตามลำดับที่เข้มงวด
ท้ายที่สุดแล้ว เราต้องกลับไปสู่พื้นฐาน: รวมข้อมูลโดเมนและตรรกะทางธุรกิจโดเมนไว้ในไมโครเซอร์วิสเดียว แนวทางนี้ช่วยลดความยุ่งยากในการรับรู้ไมโครเซอร์วิสซึ่งเป็นองค์ประกอบสำคัญของระบบ และไม่ก่อให้เกิดปัญหาข้างต้น สิ่งนี้ไม่ได้ให้ฟรีเช่นกัน:
- จำเป็นต้องมีการกำหนดมาตรฐาน API สำหรับการโต้ตอบกับตรรกะทางธุรกิจ (โดยเฉพาะเพื่อให้กิจกรรมผู้ใช้เป็นส่วนหนึ่งของกระบวนการทางธุรกิจ) และบริการแพลตฟอร์ม API ต้องการความเอาใจใส่อย่างระมัดระวังมากขึ้นต่อการเปลี่ยนแปลง API ความเข้ากันได้แบบไปข้างหน้าและย้อนกลับ
- จำเป็นต้องเพิ่มรันไทม์ไลบรารีเพิ่มเติมเพื่อให้แน่ใจว่าการทำงานของตรรกะทางธุรกิจเป็นส่วนหนึ่งของไมโครเซอร์วิสดังกล่าว และทำให้เกิดข้อกำหนดใหม่สำหรับไลบรารีดังกล่าว: ความเบาและการพึ่งพาสกรรมกริยาขั้นต่ำ
- นักพัฒนาตรรกะทางธุรกิจจำเป็นต้องตรวจสอบเวอร์ชันของไลบรารี: หากไมโครเซอร์วิสไม่ได้รับการสรุปผลเป็นเวลานาน ก็มีแนวโน้มว่าจะมีไลบรารีเวอร์ชันที่ล้าสมัยอยู่ นี่อาจเป็นอุปสรรคที่ไม่คาดคิดในการเพิ่มคุณสมบัติใหม่ และอาจจำเป็นต้องย้ายตรรกะทางธุรกิจเก่าของบริการดังกล่าวไปยังไลบรารีเวอร์ชันใหม่ หากมีการเปลี่ยนแปลงที่เข้ากันไม่ได้ระหว่างเวอร์ชัน
เลเยอร์ของบริการแพลตฟอร์มยังปรากฏอยู่ในสถาปัตยกรรมดังกล่าว แต่เลเยอร์นี้ไม่ได้สร้างคอนเทนเนอร์สำหรับการดำเนินการตรรกะทางธุรกิจโดเมนอีกต่อไป แต่มีเพียงสภาพแวดล้อมเท่านั้นที่ให้ฟังก์ชัน "แพลตฟอร์ม" เสริม เลเยอร์ดังกล่าวมีความจำเป็นไม่เพียงแต่เพื่อรักษาลักษณะของไมโครเซอร์วิสโดเมนแบบเบาเท่านั้น แต่ยังรวมถึงการจัดการแบบรวมศูนย์ด้วย
ตัวอย่างเช่น กิจกรรมผู้ใช้ในกระบวนการทางธุรกิจจะสร้างงาน อย่างไรก็ตาม เมื่อทำงานกับงาน ผู้ใช้จะต้องดูงานจากโดเมนทั้งหมดในรายการทั่วไป ซึ่งหมายความว่าจะต้องมีบริการลงทะเบียนงานแพลตฟอร์มที่เกี่ยวข้อง โดยไม่มีตรรกะทางธุรกิจของโดเมน การรักษาการห่อหุ้มตรรกะทางธุรกิจในบริบทดังกล่าวค่อนข้างเป็นปัญหา และนี่คือการประนีประนอมอีกประการหนึ่งของสถาปัตยกรรมนี้
บูรณาการกระบวนการทางธุรกิจผ่านสายตาของนักพัฒนาแอปพลิเคชัน
ดังที่ได้กล่าวไว้ข้างต้น นักพัฒนาแอปพลิเคชันจะต้องถูกแยกออกจากคุณลักษณะด้านเทคนิคและวิศวกรรมของการนำการโต้ตอบของแอปพลิเคชันต่างๆ ไปใช้ เพื่อให้สามารถวางใจในประสิทธิภาพการพัฒนาที่ดีได้
เรามาลองแก้ปัญหาการรวมระบบที่ค่อนข้างยากซึ่งคิดค้นขึ้นเป็นพิเศษสำหรับบทความ นี่จะเป็นงาน "เกม" ที่เกี่ยวข้องกับสามแอปพลิเคชัน โดยแต่ละแอปพลิเคชันจะกำหนดชื่อโดเมนที่แน่นอน: "app1", "app2", "app3"
ภายในแต่ละแอปพลิเคชัน กระบวนการทางธุรกิจจะเปิดตัวโดยเริ่ม "เล่นบอล" ผ่านบัสบูรณาการ ข้อความชื่อ “บอล” จะทำหน้าที่เป็นลูกบอล
กฎของเกม:
- ผู้เล่นคนแรกคือผู้ริเริ่ม เขาเชิญผู้เล่นคนอื่นเข้าร่วมเกม เริ่มเกม และสามารถจบเกมได้ตลอดเวลา
- ผู้เล่นคนอื่นประกาศการมีส่วนร่วมในเกม "ทำความรู้จัก" ซึ่งกันและกันและเป็นผู้เล่นคนแรก
- หลังจากได้รับลูกบอลแล้ว ผู้เล่นจะเลือกผู้เล่นอีกคนที่เข้าร่วมและส่งบอลให้เขา นับจำนวนการส่งทั้งหมด
- ผู้เล่นแต่ละคนมี "พลังงาน" ซึ่งจะลดลงตามการส่งบอลแต่ละครั้งโดยผู้เล่นคนนั้น เมื่อพลังงานหมด ผู้เล่นจะออกจากเกม และประกาศลาออก
- หากผู้เล่นถูกทิ้งให้อยู่ตามลำพังเขาจะประกาศการจากไปทันที
- เมื่อผู้เล่นทั้งหมดถูกกำจัด ผู้เล่นคนแรกจะประกาศว่าเกมจบลง หากเขาออกจากเกมเร็ว เขายังคงติดตามเกมจนจบ
เพื่อแก้ไขปัญหานี้ ฉันจะใช้ DSL สำหรับกระบวนการทางธุรกิจ ซึ่งช่วยให้เราสามารถอธิบายตรรกะใน Kotlin ได้อย่างกระชับโดยใช้ต้นแบบขั้นต่ำ
กระบวนการทางธุรกิจของผู้เล่นคนแรก (หรือที่เรียกว่าผู้ริเริ่มเกม) จะทำงานในแอปพลิเคชัน app1:
คลาส InitialPlayer
import ru.krista.bpm.ProcessInstance
import ru.krista.bpm.runtime.ProcessImpl
import ru.krista.bpm.runtime.constraint.UniqueConstraints
import ru.krista.bpm.runtime.dsl.processModel
import ru.krista.bpm.runtime.dsl.taskOperation
import ru.krista.bpm.runtime.instance.MessageSendInstance
data class PlayerInfo(val name: String, val domain: String, val id: String)
class PlayersList : ArrayList<PlayerInfo>()
// Это класс экземпляра процесса: инкапсулирует его внутреннее состояние
class InitialPlayer : ProcessImpl<InitialPlayer>(initialPlayerModel) {
var playerName: String by persistent("Player1")
var energy: Int by persistent(30)
var players: PlayersList by persistent(PlayersList())
var shotCounter: Int = 0
}
// Это декларация модели процесса: создается один раз, используется всеми
// экземплярами процесса соответствующего класса
val initialPlayerModel = processModel<InitialPlayer>(name = "InitialPlayer",
version = 1) {
// По правилам, первый игрок является инициатором игры и должен быть единственным
uniqueConstraint = UniqueConstraints.singleton
// Объявляем активности, из которых состоит бизнес-процесс
val sendNewGameSignal = signal<String>("NewGame")
val sendStopGameSignal = signal<String>("StopGame")
val startTask = humanTask("Start") {
taskOperation {
processCondition { players.size > 0 }
confirmation { "Подключилось ${players.size} игроков. Начинаем?" }
}
}
val stopTask = humanTask("Stop") {
taskOperation {}
}
val waitPlayerJoin = signalWait<String>("PlayerJoin") { signal ->
players.add(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
println("... join player ${signal.data} ...")
}
val waitPlayerOut = signalWait<String>("PlayerOut") { signal ->
players.remove(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
println("... player ${signal.data} is out ...")
}
val sendPlayerOut = signal<String>("PlayerOut") {
signalData = { playerName }
}
val sendHandshake = messageSend<String>("Handshake") {
messageData = { playerName }
activation = {
receiverDomain = process.players.last().domain
receiverProcessInstanceId = process.players.last().id
}
}
val throwStartBall = messageSend<Int>("Ball") {
messageData = { 1 }
activation = { selectNextPlayer() }
}
val throwBall = messageSend<Int>("Ball") {
messageData = { shotCounter + 1 }
activation = { selectNextPlayer() }
onEntry { energy -= 1 }
}
val waitBall = messageWaitData<Int>("Ball") {
shotCounter = it
}
// Теперь конструируем граф процесса из объявленных активностей
startFrom(sendNewGameSignal)
.fork("mainFork") {
next(startTask)
next(waitPlayerJoin).next(sendHandshake).next(waitPlayerJoin)
next(waitPlayerOut)
.branch("checkPlayers") {
ifTrue { players.isEmpty() }
.next(sendStopGameSignal)
.terminate()
ifElse().next(waitPlayerOut)
}
}
startTask.fork("afterStart") {
next(throwStartBall)
.branch("mainLoop") {
ifTrue { energy < 5 }.next(sendPlayerOut).next(waitBall)
ifElse().next(waitBall).next(throwBall).loop()
}
next(stopTask).next(sendStopGameSignal)
}
// Навешаем на активности дополнительные обработчики для логирования
sendNewGameSignal.onExit { println("Let's play!") }
sendStopGameSignal.onExit { println("Stop!") }
sendPlayerOut.onExit { println("$playerName: I'm out!") }
}
private fun MessageSendInstance<InitialPlayer, Int>.selectNextPlayer() {
val player = process.players.random()
receiverDomain = player.domain
receiverProcessInstanceId = player.id
println("Step ${process.shotCounter + 1}: " +
"${process.playerName} >>> ${player.name}")
}
นอกเหนือจากการดำเนินการตรรกะทางธุรกิจแล้ว โค้ดข้างต้นสามารถสร้างโมเดลออบเจ็กต์ของกระบวนการทางธุรกิจ ซึ่งสามารถมองเห็นได้ในรูปแบบของไดอะแกรม เรายังไม่ได้ติดตั้งวิชวลไลเซอร์ ดังนั้นเราจึงต้องใช้เวลาเล็กน้อยในการวาดภาพ (ในที่นี้ ฉันทำให้สัญลักษณ์ BPMN ง่ายขึ้นเล็กน้อยเกี่ยวกับการใช้เกตเพื่อปรับปรุงความสอดคล้องของไดอะแกรมด้วยโค้ดด้านล่าง):
app2 จะรวมกระบวนการทางธุรกิจของผู้เล่นอื่น:
คลาส RandomPlayer
import ru.krista.bpm.ProcessInstance
import ru.krista.bpm.runtime.ProcessImpl
import ru.krista.bpm.runtime.dsl.processModel
import ru.krista.bpm.runtime.instance.MessageSendInstance
data class PlayerInfo(val name: String, val domain: String, val id: String)
class PlayersList: ArrayList<PlayerInfo>()
class RandomPlayer : ProcessImpl<RandomPlayer>(randomPlayerModel) {
var playerName: String by input(persistent = true,
defaultValue = "RandomPlayer")
var energy: Int by input(persistent = true, defaultValue = 30)
var players: PlayersList by persistent(PlayersList())
var allPlayersOut: Boolean by persistent(false)
var shotCounter: Int = 0
val selfPlayer: PlayerInfo
get() = PlayerInfo(playerName, env.eventDispatcher.domainName, id)
}
val randomPlayerModel = processModel<RandomPlayer>(name = "RandomPlayer",
version = 1) {
val waitNewGameSignal = signalWait<String>("NewGame")
val waitStopGameSignal = signalWait<String>("StopGame")
val sendPlayerJoin = signal<String>("PlayerJoin") {
signalData = { playerName }
}
val sendPlayerOut = signal<String>("PlayerOut") {
signalData = { playerName }
}
val waitPlayerJoin = signalWaitCustom<String>("PlayerJoin") {
eventCondition = { signal ->
signal.sender.processInstanceId != process.id
&& !process.players.any { signal.sender.processInstanceId == it.id}
}
handler = { signal ->
players.add(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
}
}
val waitPlayerOut = signalWait<String>("PlayerOut") { signal ->
players.remove(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
allPlayersOut = players.isEmpty()
}
val sendHandshake = messageSend<String>("Handshake") {
messageData = { playerName }
activation = {
receiverDomain = process.players.last().domain
receiverProcessInstanceId = process.players.last().id
}
}
val receiveHandshake = messageWait<String>("Handshake") { message ->
if (!players.any { message.sender.processInstanceId == it.id}) {
players.add(PlayerInfo(
message.data!!,
message.sender.domain,
message.sender.processInstanceId))
}
}
val throwBall = messageSend<Int>("Ball") {
messageData = { shotCounter + 1 }
activation = { selectNextPlayer() }
onEntry { energy -= 1 }
}
val waitBall = messageWaitData<Int>("Ball") {
shotCounter = it
}
startFrom(waitNewGameSignal)
.fork("mainFork") {
next(sendPlayerJoin)
.branch("mainLoop") {
ifTrue { energy < 5 || allPlayersOut }
.next(sendPlayerOut)
.next(waitBall)
ifElse()
.next(waitBall)
.next(throwBall)
.loop()
}
next(waitPlayerJoin).next(sendHandshake).next(waitPlayerJoin)
next(waitPlayerOut).next(waitPlayerOut)
next(receiveHandshake).next(receiveHandshake)
next(waitStopGameSignal).terminate()
}
sendPlayerJoin.onExit { println("$playerName: I'm here!") }
sendPlayerOut.onExit { println("$playerName: I'm out!") }
}
private fun MessageSendInstance<RandomPlayer, Int>.selectNextPlayer() {
val player = if (process.players.isNotEmpty())
process.players.random()
else
process.selfPlayer
receiverDomain = player.domain
receiverProcessInstanceId = player.id
println("Step ${process.shotCounter + 1}: " +
"${process.playerName} >>> ${player.name}")
}
แผนภาพ:
ในแอปพลิเคชัน app3 เราจะสร้างผู้เล่นที่มีพฤติกรรมแตกต่างออกไปเล็กน้อย: แทนที่จะสุ่มเลือกผู้เล่นคนต่อไป เขาจะทำตามอัลกอริทึมแบบพบกันหมด:
คลาส RoundRobinPlayer
import ru.krista.bpm.ProcessInstance
import ru.krista.bpm.runtime.ProcessImpl
import ru.krista.bpm.runtime.dsl.processModel
import ru.krista.bpm.runtime.instance.MessageSendInstance
data class PlayerInfo(val name: String, val domain: String, val id: String)
class PlayersList: ArrayList<PlayerInfo>()
class RoundRobinPlayer : ProcessImpl<RoundRobinPlayer>(roundRobinPlayerModel) {
var playerName: String by input(persistent = true,
defaultValue = "RoundRobinPlayer")
var energy: Int by input(persistent = true, defaultValue = 30)
var players: PlayersList by persistent(PlayersList())
var nextPlayerIndex: Int by persistent(-1)
var allPlayersOut: Boolean by persistent(false)
var shotCounter: Int = 0
val selfPlayer: PlayerInfo
get() = PlayerInfo(playerName, env.eventDispatcher.domainName, id)
}
val roundRobinPlayerModel = processModel<RoundRobinPlayer>(
name = "RoundRobinPlayer",
version = 1) {
val waitNewGameSignal = signalWait<String>("NewGame")
val waitStopGameSignal = signalWait<String>("StopGame")
val sendPlayerJoin = signal<String>("PlayerJoin") {
signalData = { playerName }
}
val sendPlayerOut = signal<String>("PlayerOut") {
signalData = { playerName }
}
val waitPlayerJoin = signalWaitCustom<String>("PlayerJoin") {
eventCondition = { signal ->
signal.sender.processInstanceId != process.id
&& !process.players.any { signal.sender.processInstanceId == it.id}
}
handler = { signal ->
players.add(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
}
}
val waitPlayerOut = signalWait<String>("PlayerOut") { signal ->
players.remove(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
allPlayersOut = players.isEmpty()
}
val sendHandshake = messageSend<String>("Handshake") {
messageData = { playerName }
activation = {
receiverDomain = process.players.last().domain
receiverProcessInstanceId = process.players.last().id
}
}
val receiveHandshake = messageWait<String>("Handshake") { message ->
if (!players.any { message.sender.processInstanceId == it.id}) {
players.add(PlayerInfo(
message.data!!,
message.sender.domain,
message.sender.processInstanceId))
}
}
val throwBall = messageSend<Int>("Ball") {
messageData = { shotCounter + 1 }
activation = { selectNextPlayer() }
onEntry { energy -= 1 }
}
val waitBall = messageWaitData<Int>("Ball") {
shotCounter = it
}
startFrom(waitNewGameSignal)
.fork("mainFork") {
next(sendPlayerJoin)
.branch("mainLoop") {
ifTrue { energy < 5 || allPlayersOut }
.next(sendPlayerOut)
.next(waitBall)
ifElse()
.next(waitBall)
.next(throwBall)
.loop()
}
next(waitPlayerJoin).next(sendHandshake).next(waitPlayerJoin)
next(waitPlayerOut).next(waitPlayerOut)
next(receiveHandshake).next(receiveHandshake)
next(waitStopGameSignal).terminate()
}
sendPlayerJoin.onExit { println("$playerName: I'm here!") }
sendPlayerOut.onExit { println("$playerName: I'm out!") }
}
private fun MessageSendInstance<RoundRobinPlayer, Int>.selectNextPlayer() {
var idx = process.nextPlayerIndex + 1
if (idx >= process.players.size) {
idx = 0
}
process.nextPlayerIndex = idx
val player = if (process.players.isNotEmpty())
process.players[idx]
else
process.selfPlayer
receiverDomain = player.domain
receiverProcessInstanceId = player.id
println("Step ${process.shotCounter + 1}: " +
"${process.playerName} >>> ${player.name}")
}
ไม่เช่นนั้นพฤติกรรมของผู้เล่นก็ไม่แตกต่างไปจากครั้งก่อน ดังนั้นแผนภาพจึงไม่เปลี่ยนแปลง
ตอนนี้เราจำเป็นต้องมีการทดสอบเพื่อดำเนินการทั้งหมดนี้ ฉันจะให้เฉพาะรหัสของการทดสอบเท่านั้นเพื่อไม่ให้บทความยุ่งเหยิงด้วยสำเร็จรูป (อันที่จริงฉันใช้สภาพแวดล้อมการทดสอบที่สร้างขึ้นก่อนหน้านี้เพื่อทดสอบการรวมกระบวนการทางธุรกิจอื่น ๆ ):
ทดสอบเกม()
@Test
public void testGame() throws InterruptedException {
String pl2 = startProcess(app2, "RandomPlayer", playerParams("Player2", 20));
String pl3 = startProcess(app2, "RandomPlayer", playerParams("Player3", 40));
String pl4 = startProcess(app3, "RoundRobinPlayer", playerParams("Player4", 25));
String pl5 = startProcess(app3, "RoundRobinPlayer", playerParams("Player5", 35));
String pl1 = startProcess(app1, "InitialPlayer");
// Теперь нужно немного подождать, пока игроки "познакомятся" друг с другом.
// Ждать через sleep - плохое решение, зато самое простое.
// Не делайте так в серьезных тестах!
Thread.sleep(1000);
// Запускаем игру, закрывая пользовательскую активность
assertTrue(closeTask(app1, pl1, "Start"));
app1.getWaiting().waitProcessFinished(pl1);
app2.getWaiting().waitProcessFinished(pl2);
app2.getWaiting().waitProcessFinished(pl3);
app3.getWaiting().waitProcessFinished(pl4);
app3.getWaiting().waitProcessFinished(pl5);
}
private Map<String, Object> playerParams(String name, int energy) {
Map<String, Object> params = new HashMap<>();
params.put("playerName", name);
params.put("energy", energy);
return params;
}
ลองทำการทดสอบและดูบันทึก:
เอาต์พุตคอนโซล
Взята блокировка ключа lock://app1/process/InitialPlayer
Let's play!
Снята блокировка ключа lock://app1/process/InitialPlayer
Player2: I'm here!
Player3: I'm here!
Player4: I'm here!
Player5: I'm here!
... join player Player2 ...
... join player Player4 ...
... join player Player3 ...
... join player Player5 ...
Step 1: Player1 >>> Player3
Step 2: Player3 >>> Player5
Step 3: Player5 >>> Player3
Step 4: Player3 >>> Player4
Step 5: Player4 >>> Player3
Step 6: Player3 >>> Player4
Step 7: Player4 >>> Player5
Step 8: Player5 >>> Player2
Step 9: Player2 >>> Player5
Step 10: Player5 >>> Player4
Step 11: Player4 >>> Player2
Step 12: Player2 >>> Player4
Step 13: Player4 >>> Player1
Step 14: Player1 >>> Player4
Step 15: Player4 >>> Player3
Step 16: Player3 >>> Player1
Step 17: Player1 >>> Player2
Step 18: Player2 >>> Player3
Step 19: Player3 >>> Player1
Step 20: Player1 >>> Player5
Step 21: Player5 >>> Player1
Step 22: Player1 >>> Player2
Step 23: Player2 >>> Player4
Step 24: Player4 >>> Player5
Step 25: Player5 >>> Player3
Step 26: Player3 >>> Player4
Step 27: Player4 >>> Player2
Step 28: Player2 >>> Player5
Step 29: Player5 >>> Player2
Step 30: Player2 >>> Player1
Step 31: Player1 >>> Player3
Step 32: Player3 >>> Player4
Step 33: Player4 >>> Player1
Step 34: Player1 >>> Player3
Step 35: Player3 >>> Player4
Step 36: Player4 >>> Player3
Step 37: Player3 >>> Player2
Step 38: Player2 >>> Player5
Step 39: Player5 >>> Player4
Step 40: Player4 >>> Player5
Step 41: Player5 >>> Player1
Step 42: Player1 >>> Player5
Step 43: Player5 >>> Player3
Step 44: Player3 >>> Player5
Step 45: Player5 >>> Player2
Step 46: Player2 >>> Player3
Step 47: Player3 >>> Player2
Step 48: Player2 >>> Player5
Step 49: Player5 >>> Player4
Step 50: Player4 >>> Player2
Step 51: Player2 >>> Player5
Step 52: Player5 >>> Player1
Step 53: Player1 >>> Player5
Step 54: Player5 >>> Player3
Step 55: Player3 >>> Player5
Step 56: Player5 >>> Player2
Step 57: Player2 >>> Player1
Step 58: Player1 >>> Player4
Step 59: Player4 >>> Player1
Step 60: Player1 >>> Player4
Step 61: Player4 >>> Player3
Step 62: Player3 >>> Player2
Step 63: Player2 >>> Player5
Step 64: Player5 >>> Player4
Step 65: Player4 >>> Player5
Step 66: Player5 >>> Player1
Step 67: Player1 >>> Player5
Step 68: Player5 >>> Player3
Step 69: Player3 >>> Player4
Step 70: Player4 >>> Player2
Step 71: Player2 >>> Player5
Step 72: Player5 >>> Player2
Step 73: Player2 >>> Player1
Step 74: Player1 >>> Player4
Step 75: Player4 >>> Player1
Step 76: Player1 >>> Player2
Step 77: Player2 >>> Player5
Step 78: Player5 >>> Player4
Step 79: Player4 >>> Player3
Step 80: Player3 >>> Player1
Step 81: Player1 >>> Player5
Step 82: Player5 >>> Player1
Step 83: Player1 >>> Player4
Step 84: Player4 >>> Player5
Step 85: Player5 >>> Player3
Step 86: Player3 >>> Player5
Step 87: Player5 >>> Player2
Step 88: Player2 >>> Player3
Player2: I'm out!
Step 89: Player3 >>> Player4
... player Player2 is out ...
Step 90: Player4 >>> Player1
Step 91: Player1 >>> Player3
Step 92: Player3 >>> Player1
Step 93: Player1 >>> Player4
Step 94: Player4 >>> Player3
Step 95: Player3 >>> Player5
Step 96: Player5 >>> Player1
Step 97: Player1 >>> Player5
Step 98: Player5 >>> Player3
Step 99: Player3 >>> Player5
Step 100: Player5 >>> Player4
Step 101: Player4 >>> Player5
Player4: I'm out!
... player Player4 is out ...
Step 102: Player5 >>> Player1
Step 103: Player1 >>> Player3
Step 104: Player3 >>> Player1
Step 105: Player1 >>> Player3
Step 106: Player3 >>> Player5
Step 107: Player5 >>> Player3
Step 108: Player3 >>> Player1
Step 109: Player1 >>> Player3
Step 110: Player3 >>> Player5
Step 111: Player5 >>> Player1
Step 112: Player1 >>> Player3
Step 113: Player3 >>> Player5
Step 114: Player5 >>> Player3
Step 115: Player3 >>> Player1
Step 116: Player1 >>> Player3
Step 117: Player3 >>> Player5
Step 118: Player5 >>> Player1
Step 119: Player1 >>> Player3
Step 120: Player3 >>> Player5
Step 121: Player5 >>> Player3
Player5: I'm out!
... player Player5 is out ...
Step 122: Player3 >>> Player5
Step 123: Player5 >>> Player1
Player5: I'm out!
Step 124: Player1 >>> Player3
... player Player5 is out ...
Step 125: Player3 >>> Player1
Step 126: Player1 >>> Player3
Player1: I'm out!
... player Player1 is out ...
Step 127: Player3 >>> Player3
Player3: I'm out!
Step 128: Player3 >>> Player3
... player Player3 is out ...
Player3: I'm out!
Stop!
Step 129: Player3 >>> Player3
Player3: I'm out!
จากทั้งหมดนี้เราสามารถสรุปข้อสรุปที่สำคัญหลายประการ:
- ด้วยเครื่องมือที่จำเป็น นักพัฒนาแอปพลิเคชันสามารถสร้างการโต้ตอบบูรณาการระหว่างแอปพลิเคชันได้โดยไม่รบกวนตรรกะทางธุรกิจ
- ความซับซ้อนของงานบูรณาการที่ต้องใช้ความสามารถทางวิศวกรรมสามารถซ่อนอยู่ภายในกรอบงานได้ หากรวมอยู่ในสถาปัตยกรรมของกรอบงานตั้งแต่แรก ความยากของปัญหาไม่สามารถซ่อนไว้ได้ ดังนั้นวิธีแก้ไขปัญหาที่ยากในโค้ดจะมีลักษณะเช่นนี้
- เมื่อพัฒนาตรรกะการบูรณาการ จำเป็นต้องคำนึงถึงความสอดคล้องในที่สุดและการขาดความสามารถในการเชิงเส้นตรงของการเปลี่ยนแปลงในสถานะของผู้เข้าร่วมการบูรณาการทั้งหมด สิ่งนี้บังคับให้เราต้องทำให้ตรรกะซับซ้อนเพื่อที่จะทำให้มันไม่คำนึงถึงลำดับของเหตุการณ์ภายนอกที่เกิดขึ้น ในตัวอย่างของเรา ผู้เล่นถูกบังคับให้มีส่วนร่วมในเกมหลังจากที่เขาประกาศออกจากเกมแล้ว ผู้เล่นคนอื่นๆ จะส่งบอลให้เขาต่อไปจนกว่าข้อมูลเกี่ยวกับทางออกของเขาจะมาถึง และผู้เข้าร่วมทั้งหมดจะประมวลผล ตรรกะนี้ไม่เป็นไปตามกฎของเกมและเป็นวิธีแก้ปัญหาแบบประนีประนอมภายในกรอบงานของสถาปัตยกรรมที่เลือก
ต่อไป เราจะพูดถึงความซับซ้อนต่างๆ ของโซลูชัน การประนีประนอม และประเด็นอื่นๆ
ข้อความทั้งหมดอยู่ในคิวเดียว
แอปพลิเคชันแบบรวมทั้งหมดทำงานร่วมกับบัสการรวมหนึ่งรายการ ซึ่งนำเสนอในรูปแบบของนายหน้าภายนอก หนึ่ง BPMQueue สำหรับข้อความ และหนึ่งหัวข้อ BPMTopic สำหรับสัญญาณ (เหตุการณ์) การนำข้อความทั้งหมดไปไว้ในคิวเดียวถือเป็นการประนีประนอม ในระดับตรรกะทางธุรกิจ ตอนนี้คุณสามารถแนะนำประเภทข้อความใหม่ได้มากเท่าที่คุณต้องการ โดยไม่ต้องทำการเปลี่ยนแปลงโครงสร้างระบบ นี่เป็นการทำให้ง่ายขึ้นอย่างมีนัยสำคัญ แต่มีความเสี่ยงบางประการ ซึ่งในบริบทของงานทั่วไปของเราดูเหมือนจะไม่สำคัญสำหรับเรามากนัก
อย่างไรก็ตาม มีข้อละเอียดอ่อนอย่างหนึ่งที่นี่: แต่ละแอปพลิเคชันจะกรองข้อความ "ของมัน" จากคิวที่ทางเข้าตามชื่อโดเมน โดเมนยังสามารถระบุในสัญญาณได้ หากคุณต้องการจำกัด "ขอบเขตการมองเห็น" ของสัญญาณให้เหลือเพียงแอปพลิเคชันเดียว สิ่งนี้ควรเพิ่มปริมาณงานของบัส แต่ตรรกะทางธุรกิจต้องดำเนินการกับชื่อโดเมน: สำหรับการจัดการข้อความ - จำเป็น สำหรับสัญญาณ - เป็นที่ต้องการ
รับประกันความน่าเชื่อถือของ Integration Bus
ความน่าเชื่อถือประกอบด้วยหลายจุด:
- นายหน้าข้อความที่เลือกเป็นองค์ประกอบที่สำคัญของสถาปัตยกรรมและเป็นจุดเดียวของความล้มเหลว: จะต้องทนทานต่อข้อผิดพลาดอย่างเพียงพอ คุณควรใช้เฉพาะการใช้งานที่ผ่านการทดสอบตามเวลา พร้อมการสนับสนุนที่ดีและชุมชนขนาดใหญ่
- จำเป็นต้องตรวจสอบให้แน่ใจว่านายหน้าข้อความมีความพร้อมใช้งานสูง ซึ่งจะต้องแยกทางกายภาพออกจากแอปพลิเคชันแบบรวม (ความพร้อมใช้งานสูงของแอปพลิเคชันที่มีตรรกะทางธุรกิจที่ประยุกต์ใช้นั้นยากและมีราคาแพงกว่ามากในการรับรอง)
- นายหน้ามีหน้าที่ต้องรับประกันการจัดส่ง "อย่างน้อยหนึ่งครั้ง" นี่เป็นข้อกำหนดบังคับสำหรับการทำงานที่เชื่อถือได้ของบัสรวม ไม่จำเป็นต้องมีการรับประกันระดับ "เพียงครั้งเดียว": ตามกฎแล้วกระบวนการทางธุรกิจจะไม่อ่อนไหวต่อการมาถึงของข้อความหรือเหตุการณ์ซ้ำๆ และในงานพิเศษซึ่งเป็นสิ่งสำคัญ จะเพิ่มการตรวจสอบเพิ่มเติมให้กับธุรกิจได้ง่ายกว่า ตรรกะมากกว่าการใช้การรับประกันที่ค่อนข้าง "แพง" อย่างต่อเนื่อง
- การส่งข้อความและสัญญาณจะต้องเกี่ยวข้องกับธุรกรรมโดยรวมที่มีการเปลี่ยนแปลงสถานะของกระบวนการทางธุรกิจและข้อมูลโดเมน ตัวเลือกที่ต้องการคือการใช้รูปแบบ
กล่องขาออกของธุรกรรม แต่จะต้องมีตารางเพิ่มเติมในฐานข้อมูลและตัวทวนสัญญาณ ในแอปพลิเคชัน JEE สิ่งนี้สามารถทำให้ง่ายขึ้นได้โดยใช้ผู้จัดการ JTA ในพื้นที่ แต่การเชื่อมต่อกับโบรกเกอร์ที่เลือกจะต้องสามารถทำงานได้XA ; - ตัวจัดการข้อความและเหตุการณ์ขาเข้าจะต้องทำงานกับธุรกรรมที่เปลี่ยนสถานะของกระบวนการทางธุรกิจ: หากธุรกรรมดังกล่าวถูกย้อนกลับ การรับข้อความจะต้องถูกยกเลิก
- ข้อความที่ไม่สามารถส่งได้เนื่องจากข้อผิดพลาดจะต้องจัดเก็บไว้ในที่เก็บข้อมูลแยกต่างหาก
ดีแอลคิว (คิวจดหมายตาย) เพื่อจุดประสงค์นี้ เราได้สร้างไมโครเซอร์วิสแพลตฟอร์มแยกต่างหากที่เก็บข้อความดังกล่าวในพื้นที่จัดเก็บข้อมูล จัดทำดัชนีข้อความตามคุณลักษณะ (สำหรับการจัดกลุ่มและการค้นหาอย่างรวดเร็ว) และเปิดเผย API สำหรับการดู ส่งอีกครั้งไปยังที่อยู่ปลายทาง และการลบข้อความ ผู้ดูแลระบบสามารถทำงานกับบริการนี้ผ่านทางเว็บอินเตอร์เฟส - ในการตั้งค่านายหน้า คุณต้องปรับจำนวนการลองส่งซ้ำและความล่าช้าระหว่างการส่งเพื่อลดโอกาสที่ข้อความจะเข้าสู่ DLQ (แทบจะเป็นไปไม่ได้เลยที่จะคำนวณพารามิเตอร์ที่เหมาะสมที่สุด แต่คุณสามารถดำเนินการเชิงประจักษ์และปรับเปลี่ยนระหว่างการดำเนินการได้ );
- ที่เก็บ DLQ จะต้องได้รับการตรวจสอบอย่างต่อเนื่อง และระบบตรวจสอบจะต้องแจ้งเตือนผู้ดูแลระบบเพื่อว่าเมื่อข้อความที่ไม่ได้จัดส่งเกิดขึ้น พวกเขาสามารถตอบสนองได้อย่างรวดเร็วที่สุด วิธีนี้จะช่วยลด “พื้นที่ที่ได้รับผลกระทบ” ของความล้มเหลวหรือข้อผิดพลาดทางตรรกะทางธุรกิจ
- บัสการรวมจะต้องไม่ไวต่อการไม่มีแอปพลิเคชันชั่วคราว: การสมัครสมาชิกหัวข้อจะต้องมีความคงทน และชื่อโดเมนของแอปพลิเคชันจะต้องไม่ซ้ำกัน เพื่อที่ในขณะที่แอปพลิเคชันหายไป บุคคลอื่นจะไม่พยายามประมวลผลข้อความจาก คิว.
รับรองความปลอดภัยของเธรดของตรรกะทางธุรกิจ
อินสแตนซ์เดียวกันของกระบวนการทางธุรกิจสามารถรับข้อความและเหตุการณ์ต่างๆ ได้ในคราวเดียว ซึ่งการประมวลผลจะเริ่มพร้อมกัน ในเวลาเดียวกัน สำหรับนักพัฒนาแอปพลิเคชัน ทุกอย่างควรเรียบง่ายและปลอดภัยสำหรับเธรด
ตรรกะทางธุรกิจของกระบวนการจะประมวลผลเหตุการณ์ภายนอกแต่ละรายการที่ส่งผลต่อกระบวนการทางธุรกิจนั้นทีละรายการ เหตุการณ์ดังกล่าวอาจเป็น:
- การเปิดตัวอินสแตนซ์กระบวนการทางธุรกิจ
- การกระทำของผู้ใช้ที่เกี่ยวข้องกับกิจกรรมภายในกระบวนการทางธุรกิจ
- การรับข้อความหรือสัญญาณที่สมัครรับอินสแตนซ์กระบวนการทางธุรกิจ
- การทริกเกอร์ตัวจับเวลาที่กำหนดโดยอินสแตนซ์กระบวนการทางธุรกิจ
- ควบคุมการดำเนินการผ่าน API (เช่น การหยุดชะงักของกระบวนการ)
แต่ละเหตุการณ์ดังกล่าวสามารถเปลี่ยนสถานะของอินสแตนซ์กระบวนการทางธุรกิจได้: กิจกรรมบางอย่างอาจสิ้นสุดและบางกิจกรรมอาจเริ่มต้นขึ้น และค่าของคุณสมบัติถาวรอาจเปลี่ยนแปลงได้ การปิดกิจกรรมใดๆ อาจส่งผลให้มีการเปิดใช้งานกิจกรรมต่อไปนี้อย่างน้อยหนึ่งกิจกรรม ในทางกลับกันสามารถหยุดรอเหตุการณ์อื่น ๆ หรือหากไม่ต้องการข้อมูลเพิ่มเติมก็สามารถดำเนินการในธุรกรรมเดียวกันได้ ก่อนที่จะปิดธุรกรรม สถานะใหม่ของกระบวนการทางธุรกิจจะถูกบันทึกไว้ในฐานข้อมูล ซึ่งจะรอให้เหตุการณ์ภายนอกถัดไปเกิดขึ้น
ข้อมูลกระบวนการทางธุรกิจแบบถาวรที่จัดเก็บไว้ในฐานข้อมูลเชิงสัมพันธ์เป็นจุดที่สะดวกมากสำหรับการซิงโครไนซ์การประมวลผล หากคุณใช้ SELECT FOR UPDATE หากธุรกรรมหนึ่งจัดการเพื่อรับสถานะของกระบวนการทางธุรกิจจากฐานสำหรับการเปลี่ยนแปลง จะไม่มีธุรกรรมอื่นใดแบบคู่ขนานที่จะได้รับสถานะเดียวกันสำหรับการเปลี่ยนแปลงอีกครั้ง และหลังจากเสร็จสิ้นธุรกรรมแรกแล้ว รายการที่สองคือ รับประกันว่าจะได้รับสถานะที่เปลี่ยนแปลงไปแล้ว
การใช้การล็อกในแง่ร้ายในด้าน DBMS ทำให้เราสามารถปฏิบัติตามข้อกำหนดที่จำเป็นทั้งหมดได้
อย่างไรก็ตาม การล็อกในแง่ร้ายคุกคามเราด้วยการชะงักงัน ซึ่งหมายความว่า SELECT FOR UPDATE ยังคงถูกจำกัดอยู่เพียงการหมดเวลาที่เหมาะสมในกรณีที่เกิดการชะงักงันในบางกรณีร้ายแรงในตรรกะทางธุรกิจ
ปัญหาอีกประการหนึ่งคือการซิงโครไนซ์การเริ่มต้นกระบวนการทางธุรกิจ แม้ว่าจะไม่มีอินสแตนซ์ของกระบวนการทางธุรกิจ แต่ไม่มีสถานะในฐานข้อมูล ดังนั้นวิธีที่อธิบายไว้จะไม่ทำงาน หากคุณต้องการตรวจสอบความเป็นเอกลักษณ์ของอินสแตนซ์กระบวนการทางธุรกิจในขอบเขตเฉพาะ คุณจะต้องมีออบเจ็กต์การซิงโครไนซ์บางประเภทที่เชื่อมโยงกับคลาสกระบวนการและขอบเขตที่เกี่ยวข้อง เพื่อแก้ไขปัญหานี้ เราใช้กลไกการล็อกอื่นที่ช่วยให้เราสามารถล็อกทรัพยากรที่กำหนดโดยคีย์ในรูปแบบ URI ผ่านบริการภายนอกได้
ในตัวอย่างของเรา กระบวนการทางธุรกิจของ InitialPlayer มีการประกาศ
uniqueConstraint = UniqueConstraints.singleton
ดังนั้นบันทึกจึงมีข้อความเกี่ยวกับการรับและปลดล็อคกุญแจที่เกี่ยวข้อง ไม่มีข้อความดังกล่าวสำหรับกระบวนการทางธุรกิจอื่น: ไม่ได้ตั้งค่า UniqueConstraint
ปัญหาของกระบวนการทางธุรกิจที่มีสถานะคงอยู่
บางครั้งการมีสภาวะคงอยู่ไม่เพียงช่วยเท่านั้น แต่ยังเป็นอุปสรรคต่อการพัฒนาอีกด้วย
ปัญหาเริ่มต้นเมื่อจำเป็นต้องเปลี่ยนแปลงตรรกะทางธุรกิจและ/หรือแบบจำลองกระบวนการทางธุรกิจ ไม่ใช่ทุกการเปลี่ยนแปลงดังกล่าวจะเข้ากันได้กับกระบวนการทางธุรกิจในสถานะเก่า หากมีอินสแตนซ์สดจำนวนมากในฐานข้อมูล การทำการเปลี่ยนแปลงที่เข้ากันไม่ได้อาจทำให้เกิดปัญหามากมาย ซึ่งเรามักพบเมื่อใช้ jBPM
คุณสามารถดำเนินการได้สองวิธี ทั้งนี้ขึ้นอยู่กับความลึกของการเปลี่ยนแปลง:
- สร้างประเภทกระบวนการทางธุรกิจใหม่เพื่อไม่ให้ทำการเปลี่ยนแปลงที่เข้ากันไม่ได้กับอันเก่า และใช้แทนอันเก่าเมื่อเปิดตัวอินสแตนซ์ใหม่ สำเนาเก่าจะยังคงใช้งานได้ "เหมือนเดิม"
- ย้ายสถานะกระบวนการทางธุรกิจที่คงอยู่เมื่ออัปเดตตรรกะทางธุรกิจ
วิธีแรกนั้นง่ายกว่า แต่ก็มีข้อจำกัดและข้อเสีย เช่น:
- การทำซ้ำตรรกะทางธุรกิจในแบบจำลองกระบวนการทางธุรกิจจำนวนมาก การเพิ่มปริมาณตรรกะทางธุรกิจ
- บ่อยครั้งที่จำเป็นต้องเปลี่ยนมาใช้ตรรกะทางธุรกิจใหม่ทันที (ในแง่ของงานบูรณาการ - เกือบทุกครั้ง)
- นักพัฒนาไม่ทราบว่าสามารถลบโมเดลที่ล้าสมัยได้ในจุดใด
ในทางปฏิบัติเราใช้ทั้งสองแนวทาง แต่ได้ตัดสินใจหลายอย่างเพื่อทำให้ชีวิตของเราง่ายขึ้น:
- ในฐานข้อมูล สถานะถาวรของกระบวนการทางธุรกิจจะถูกจัดเก็บไว้ในรูปแบบที่อ่านง่ายและประมวลผลได้ง่าย: ในรูปแบบสตริง JSON ซึ่งช่วยให้สามารถดำเนินการย้ายข้อมูลได้ทั้งภายในแอปพลิเคชันและภายนอก ทางเลือกสุดท้าย คุณสามารถแก้ไขได้ด้วยตนเอง (โดยเฉพาะอย่างยิ่งมีประโยชน์ในการพัฒนาระหว่างการดีบัก)
- ตรรกะทางธุรกิจแบบรวมไม่ได้ใช้ชื่อของกระบวนการทางธุรกิจ เพื่อให้สามารถแทนที่การใช้งานกระบวนการใดกระบวนการหนึ่งที่เข้าร่วมด้วยกระบวนการใหม่ที่มีชื่อใหม่ได้ตลอดเวลา (เช่น "InitialPlayerV2") การเชื่อมโยงเกิดขึ้นผ่านข้อความและชื่อสัญญาณ
- โมเดลกระบวนการมีหมายเลขเวอร์ชัน ซึ่งเราจะเพิ่มหากเราทำการเปลี่ยนแปลงที่เข้ากันไม่ได้กับโมเดลนี้ และหมายเลขนี้จะถูกบันทึกพร้อมกับสถานะของอินสแตนซ์กระบวนการ
- สถานะถาวรของกระบวนการจะถูกอ่านจากฐานข้อมูลก่อนเป็นโมเดลอ็อบเจ็กต์ที่สะดวก ซึ่งขั้นตอนการย้ายข้อมูลสามารถทำงานได้หากหมายเลขเวอร์ชันของโมเดลมีการเปลี่ยนแปลง
- ขั้นตอนการย้ายข้อมูลจะถูกวางไว้ถัดจากตรรกะทางธุรกิจและเรียกว่า "ขี้เกียจ" สำหรับแต่ละอินสแตนซ์ของกระบวนการทางธุรกิจในขณะที่กู้คืนจากฐานข้อมูล
- หากคุณต้องการย้ายสถานะของอินสแตนซ์กระบวนการทั้งหมดอย่างรวดเร็วและพร้อมกัน จะมีการใช้โซลูชันการย้ายฐานข้อมูลแบบคลาสสิกมากกว่านี้ แต่คุณต้องทำงานกับ JSON
คุณต้องการเฟรมเวิร์กอื่นสำหรับกระบวนการทางธุรกิจหรือไม่?
โซลูชันที่อธิบายไว้ในบทความช่วยให้เราทำให้ชีวิตของเราง่ายขึ้นอย่างมาก ขยายขอบเขตของปัญหาที่แก้ไขได้ในระดับการพัฒนาแอปพลิเคชัน และทำให้แนวคิดในการแยกตรรกะทางธุรกิจออกเป็นไมโครเซอร์วิสมีความน่าสนใจยิ่งขึ้น เพื่อให้บรรลุเป้าหมายนี้ งานจำนวนมากได้เสร็จสิ้นลง โดยมีการสร้างกรอบงาน "น้ำหนักเบา" มากสำหรับกระบวนการทางธุรกิจ รวมถึงส่วนประกอบบริการเพื่อแก้ไขปัญหาที่ระบุในบริบทของปัญหาแอปพลิเคชันที่หลากหลาย เรามีความปรารถนาที่จะแบ่งปันผลลัพธ์เหล่านี้ และทำให้การพัฒนาส่วนประกอบทั่วไปเข้าถึงได้แบบเปิดภายใต้ใบอนุญาตฟรี การดำเนินการนี้จะต้องใช้ความพยายามและเวลา การทำความเข้าใจความต้องการโซลูชันดังกล่าวอาจเป็นแรงจูงใจเพิ่มเติมสำหรับเรา ในบทความที่เสนอ มีการให้ความสนใจน้อยมากกับความสามารถของกรอบงานเอง แต่บางส่วนก็มองเห็นได้จากตัวอย่างที่นำเสนอ หากเราเผยแพร่เฟรมเวิร์กของเรา จะมีบทความแยกต่างหากไว้สำหรับเฟรมเวิร์กนั้น ในระหว่างนี้ เราจะยินดีอย่างยิ่งหากคุณแสดงความคิดเห็นเล็กๆ น้อยๆ ด้วยการตอบคำถาม:
เฉพาะผู้ใช้ที่ลงทะเบียนเท่านั้นที่สามารถเข้าร่วมในการสำรวจได้
คุณต้องการเฟรมเวิร์กอื่นสำหรับกระบวนการทางธุรกิจหรือไม่?
-
ลด 18,8%ใช่ ฉันมองหาอะไรแบบนี้มานานแล้ว
-
ลด 12,5%ฉันสนใจที่จะเรียนรู้เพิ่มเติมเกี่ยวกับการใช้งานของคุณ ซึ่งอาจเป็นประโยชน์2
-
ลด 6,2%เราใช้หนึ่งในเฟรมเวิร์กที่มีอยู่ แต่กำลังคิดที่จะแทนที่1
-
ลด 18,8%เราใช้หนึ่งในเฟรมเวิร์กที่มีอยู่ ทุกอย่างเรียบร้อยดี3
-
ลด 18,8%เราจัดการโดยไม่มีกรอบ3
-
ลด 25,0%เขียนของคุณ4
ผู้ใช้ 16 คนโหวต ผู้ใช้ 7 รายงดออกเสียง
ที่มา: will.com