หลักความรับผิดชอบเดียว: สูตรสำหรับรหัสที่ยอดเยี่ยม
เผยแพร่แล้ว: 2022-03-11ไม่ว่าสิ่งที่เราพิจารณาว่าเป็นโค้ดที่ยอดเยี่ยม ก็ต้องการคุณภาพที่เรียบง่ายเพียงอย่างเดียว นั่นคือโค้ดต้องสามารถบำรุงรักษาได้ การเยื้องที่เหมาะสม ชื่อตัวแปรที่เรียบร้อย การครอบคลุมการทดสอบ 100% และอื่นๆ สามารถนำคุณไปไกลได้เท่านั้น รหัสใด ๆ ที่ไม่สามารถบำรุงรักษาได้และไม่สามารถปรับให้เข้ากับความต้องการที่เปลี่ยนแปลงได้อย่างง่ายดายคือรหัสที่รอให้ล้าสมัย เราอาจไม่จำเป็นต้องเขียนโค้ดที่ยอดเยี่ยมเมื่อเราพยายามสร้างต้นแบบ การพิสูจน์แนวคิด หรือผลิตภัณฑ์ที่ใช้งานได้ขั้นต่ำ แต่ในกรณีอื่นๆ ทั้งหมด เราควรเขียนโค้ดที่สามารถบำรุงรักษาได้เสมอ นี่คือสิ่งที่ควรพิจารณาคุณภาพพื้นฐานของวิศวกรรมซอฟต์แวร์และการออกแบบ
ในบทความนี้ ผมจะพูดถึงวิธีที่ Single Responsibility Principle และเทคนิคบางอย่างที่เกี่ยวข้องกับมันสามารถทำให้โค้ดของคุณมีคุณภาพเช่นนี้ การเขียนโค้ดที่ยอดเยี่ยมเป็นศิลปะอย่างหนึ่ง แต่หลักการบางอย่างสามารถช่วยให้การพัฒนาของคุณมีทิศทางที่จำเป็นสำหรับการผลิตซอฟต์แวร์ที่ทนทานและบำรุงรักษาได้เสมอ
โมเดลคือทุกสิ่ง
หนังสือเกือบทุกเล่มเกี่ยวกับเฟรมเวิร์ก MVC (MVP, MVVM หรือ M**) ใหม่บางเล่มเต็มไปด้วยตัวอย่างโค้ดที่ไม่ถูกต้อง ตัวอย่างเหล่านี้พยายามแสดงให้เห็นว่ามีกรอบงานใดบ้าง แต่พวกเขาก็จบลงด้วยการให้คำแนะนำที่ไม่ดีสำหรับผู้เริ่มต้น ตัวอย่าง เช่น “สมมติว่าเรามี ORM X นี้สำหรับโมเดลของเรา เทมเพลตเอ็นจิ้น Y สำหรับมุมมองของเรา และเราจะมีตัวควบคุมเพื่อจัดการทั้งหมด” ไม่ได้ทำอะไรเลยนอกจากตัวควบคุมขนาดมหึมา
แม้ว่าเพื่อป้องกันหนังสือเหล่านี้ ตัวอย่างมีขึ้นเพื่อแสดงให้เห็นถึงความสะดวกที่คุณสามารถเริ่มต้นกับกรอบการทำงานได้ ไม่ได้มีไว้สำหรับสอนการออกแบบซอฟต์แวร์ แต่ผู้อ่านที่ติดตามตัวอย่างเหล่านี้ตระหนักดีว่าหลังจากผ่านไปหลายปีเท่านั้น การมีโค้ดจำนวนมากในโครงการของพวกเขานั้นเป็นผลดีเพียงใด
โมเดลคือหัวใจของแอปของคุณ หากคุณมีแบบจำลองแยกจากตรรกะแอปพลิเคชันที่เหลือ การบำรุงรักษาจะง่ายขึ้นมาก ไม่ว่าแอปพลิเคชันของคุณจะซับซ้อนเพียงใด แม้แต่สำหรับแอปพลิเคชันที่ซับซ้อน การใช้โมเดลที่ดีก็อาจส่งผลให้เกิดโค้ดที่แสดงออกอย่างมาก และเพื่อให้บรรลุเป้าหมายนั้น ให้เริ่มต้นด้วยการทำให้แน่ใจว่าโมเดลของคุณทำในสิ่งที่พวกเขาต้องการทำเท่านั้น และอย่ากังวลกับสิ่งที่แอพสร้างขึ้นมา นอกจากนี้ แอปของคุณไม่เกี่ยวข้องกับชั้นการจัดเก็บข้อมูลพื้นฐาน: แอปของคุณต้องพึ่งพาฐานข้อมูล SQL หรือจัดเก็บทุกอย่างไว้ในไฟล์ข้อความหรือไม่
เมื่อเราดำเนินการต่อในบทความนี้ คุณจะรู้ว่าโค้ดมีความสำคัญมากเพียงใดเกี่ยวกับการแยกข้อกังวล
หลักการความรับผิดชอบเดียว
คุณอาจเคยได้ยินเกี่ยวกับหลักการของ SOLID: ความรับผิดชอบเดียว เปิด-ปิด การแทนที่ liskov การแยกส่วนต่อประสาน และการผกผันการพึ่งพา อักษรตัวแรก S หมายถึง Single Responsibility Principle (SRP) และความสำคัญของมันไม่สามารถพูดเกินจริงได้ ฉันยังยืนยันว่ามันเป็นเงื่อนไขที่จำเป็นและเพียงพอสำหรับรหัสที่ดี อันที่จริงแล้ว ในโค้ดใดๆ ที่เขียนไม่ดี คุณสามารถหาคลาสที่มีความรับผิดชอบมากกว่าหนึ่งได้เสมอ - form1.cs หรือ index.php ที่มีโค้ดไม่กี่พันบรรทัดไม่ใช่สิ่งที่หายากและพวกเราทุกคน คงเคยเห็นหรือทำไปแล้ว
มาดูตัวอย่างใน C# (ASP.NET MVC และ Entity framework) แม้ว่าคุณจะไม่ใช่นักพัฒนา C# แต่ด้วยประสบการณ์ OOP คุณจะสามารถติดตามได้อย่างง่ายดาย
public class OrderController { ... public ActionResult CreateForm() { /* * View data preparations */ return View(); } [HttpPost] public ActionResult Create(OrderCreateRequest request) { if (!ModelState.IsValid) { /* * View data preparations */ return View(); } using (var context = new DataContext()) { var order = new Order(); // Create order from request context.Orders.Add(order); // Reserve ordered goods …(Huge logic here)... context.SaveChanges(); //Send email with order details for customer } return RedirectToAction("Index"); } ... (many more methods like Create here) }
นี่คือคลาส OrderController ปกติ วิธีการสร้างจะแสดงขึ้น ในตัวควบคุมแบบนี้ ฉันมักจะเห็นกรณีที่คลาส Order ถูกใช้เป็นพารามิเตอร์คำขอ แต่ฉันชอบที่จะใช้คลาสคำขอพิเศษ ย้ำ SRP!
โปรดสังเกตในข้อมูลโค้ดด้านบนว่าผู้ควบคุมรู้มากเกินไปเกี่ยวกับ "การสั่งซื้อ" ได้อย่างไร ซึ่งรวมถึงแต่ไม่จำกัดเพียงการจัดเก็บออบเจ็กต์ Order การส่งอีเมล ฯลฯ นั่นเป็นงานมากเกินไปสำหรับคลาสเดียว สำหรับการเปลี่ยนแปลงเล็กๆ น้อยๆ ทุกๆ อย่าง นักพัฒนาจำเป็นต้องเปลี่ยนโค้ดของคอนโทรลเลอร์ทั้งหมด และในกรณีที่ผู้ควบคุมรายอื่นจำเป็นต้องสร้างคำสั่งซื้อด้วย บ่อยครั้งกว่าที่นักพัฒนาซอฟต์แวร์จะใช้วิธีคัดลอกและวางโค้ด ผู้ควบคุมควรควบคุมเฉพาะกระบวนการโดยรวมเท่านั้น ไม่ได้จัดเก็บตรรกะของกระบวนการทุกบิต
แต่วันนี้เป็นวันที่เราหยุดเขียนคอนโทรลเลอร์ขนาดมหึมาเหล่านี้!
ก่อนอื่นให้เราแยกตรรกะทางธุรกิจทั้งหมดออกจากคอนโทรลเลอร์และย้ายไปยังคลาส OrderService:
public class OrderService { public void Create(OrderCreateRequest request) { // all actions for order creating here } } public class OrderController { public OrderController() { this.service = new OrderService(); } [HttpPost] public ActionResult Create(OrderCreateRequest request) { if (!ModelState.IsValid) { /* * View data preparations */ return View(); } this.service.Create(request); return RedirectToAction("Index"); }
เมื่อทำเสร็จแล้ว ตัวควบคุมจะทำเฉพาะสิ่งที่ตั้งใจจะทำเท่านั้น นั่นคือ ควบคุมกระบวนการ มันรู้แค่เกี่ยวกับมุมมอง คลาส OrderService และ OrderRequest ซึ่งเป็นชุดข้อมูลที่น้อยที่สุดที่จำเป็นสำหรับการทำงาน ซึ่งก็คือการจัดการคำขอและการส่งการตอบกลับ
วิธีนี้จะทำให้คุณแทบไม่ต้องเปลี่ยนรหัสคอนโทรลเลอร์ ส่วนประกอบอื่นๆ เช่น มุมมอง อ็อบเจ็กต์คำขอ และบริการยังคงสามารถเปลี่ยนแปลงได้เนื่องจากเชื่อมโยงกับข้อกำหนดทางธุรกิจ แต่ไม่ใช่ผู้ควบคุม
นี่คือสิ่งที่เกี่ยวกับ SRP และมีเทคนิคมากมายในการเขียนโค้ดที่ตรงตามหลักการนี้ ตัวอย่างหนึ่งคือการฉีดพึ่งพา (สิ่งที่มีประโยชน์สำหรับการเขียนโค้ดที่ทดสอบได้)
การฉีดพึ่งพา
เป็นการยากที่จะจินตนาการถึงโครงการขนาดใหญ่ที่อิงตามหลักการความรับผิดชอบเดียวโดยไม่ต้องพึ่งพาการฉีด ให้เราดูที่คลาส OrderService ของเราอีกครั้ง:
public class OrderService { public void Create(...) { // Creating the order(and let's forget about reserving here, it's not important for following examples) // Sending an email to client with order details var smtp = new SMTP(); // Setting smtp.Host, UserName, Password and other parameters smtp.Send(); } }
รหัสนี้ใช้งานได้ แต่ไม่เหมาะนัก เพื่อให้เข้าใจวิธีการทำงานของคลาส OrderService ในการสร้าง พวกเขาถูกบังคับให้เข้าใจความซับซ้อนของ SMTP และอีกครั้ง การคัดลอกและวางเป็นวิธีเดียวที่จะทำซ้ำการใช้ SMTP นี้ได้ทุกที่ที่จำเป็น แต่ด้วยการปรับโครงสร้างใหม่เล็กน้อย สิ่งนั้นสามารถเปลี่ยนแปลงได้:
public class OrderService { private SmtpMailer mailer; public OrderService() { this.mailer = new SmtpMailer(); } public void Create(...) { // Creating the order // Sending an email to client with order details this.mailer.Send(...); } } public class SmtpMailer { public void Send(string to, string subject, string body) { // SMTP stuff will be only here } }
ดีขึ้นมากแล้ว! แต่คลาส OrderService ยังรู้มากเกี่ยวกับการส่งอีเมล ต้องใช้คลาส SmtpMailer เพื่อส่งอีเมล เกิดอะไรขึ้นถ้าเราต้องการเปลี่ยนแปลงในอนาคต? จะเป็นอย่างไรถ้าเราต้องการพิมพ์เนื้อหาของอีเมลที่ส่งไปยังไฟล์บันทึกพิเศษแทนที่จะส่งจริงในสภาพแวดล้อมการพัฒนาของเรา จะเป็นอย่างไรถ้าเราต้องการทดสอบคลาส OrderService ของเรา ให้เราดำเนินการปรับโครงสร้างใหม่โดยสร้างอินเทอร์เฟซ IMAiler:

public interface IMailer { void Send(string to, string subject, string body); }
SmtpMailer จะใช้อินเทอร์เฟซนี้ นอกจากนี้ แอปพลิเคชันของเราจะใช้คอนเทนเนอร์ IoC และเราสามารถกำหนดค่าเพื่อให้ IMailer ถูกใช้งานโดยคลาส SmtpMailer OrderService สามารถเปลี่ยนแปลงได้ดังนี้:
public sealed class OrderService: IOrderService { private IOrderRepository repository; private IMailer mailer; public OrderService(IOrderRepository repository, IMailer mailer) { this.repository = repository; this.mailer = mailer; } public void Create(...) { var order = new Order(); // fill the Order entity using the full power of our Business Logic(discounts, promotions, etc.) this.repository.Save(order); this.mailer.Send(<orders user email>, <subject>, <body with order details>); } }
ตอนนี้เรากำลังจะไปที่ไหนสักแห่ง! ฉันใช้โอกาสนี้เพื่อทำการเปลี่ยนแปลงอีกครั้ง ขณะนี้ OrderService อาศัยอินเทอร์เฟซ IOrderRepository เพื่อโต้ตอบกับส่วนประกอบที่จัดเก็บคำสั่งซื้อทั้งหมดของเรา ไม่สนใจว่าอินเทอร์เฟซนั้นใช้งานอย่างไรและเทคโนโลยีการจัดเก็บข้อมูลใดที่เปิดใช้งาน ตอนนี้คลาส OrderService มีเฉพาะรหัสที่เกี่ยวข้องกับตรรกะทางธุรกิจของคำสั่งซื้อ
ด้วยวิธีนี้ หากผู้ทดสอบพบว่ามีบางอย่างทำงานไม่ถูกต้องในการส่งอีเมล นักพัฒนาซอฟต์แวร์จะรู้ว่าต้องมองที่ใด: คลาส SmtpMailer หากมีสิ่งผิดปกติเกิดขึ้นกับส่วนลด นักพัฒนาอีกครั้ง รู้ว่าต้องดูที่ไหน: OrderService (หรือในกรณีที่คุณยอมรับ SRP ด้วยใจ อาจเป็นรหัสคลาสของ DiscountService)
สถาปัตยกรรมที่ขับเคลื่อนด้วยเหตุการณ์
อย่างไรก็ตาม ฉันยังไม่ชอบวิธี OrderService.Create:
public void Create(...) { var order = new Order(); ... this.repository.Save(order); this.mailer.Send(<orders user email>, <subject>, <body with order details>); }
การส่งอีเมลไม่ใช่ส่วนหนึ่งของขั้นตอนการสร้างคำสั่งซื้อหลัก แม้ว่าแอปจะไม่สามารถส่งอีเมลได้ แต่คำสั่งซื้อก็ยังสร้างได้อย่างถูกต้อง นอกจากนี้ ลองนึกภาพสถานการณ์ที่คุณต้องเพิ่มตัวเลือกใหม่ในพื้นที่การตั้งค่าผู้ใช้ ซึ่งช่วยให้พวกเขาเลือกไม่รับอีเมลหลังจากทำการสั่งซื้อได้สำเร็จ เพื่อรวมสิ่งนี้เข้ากับคลาส OrderService เราจะต้องแนะนำการพึ่งพา IUserParametersService เพิ่มการโลคัลไลเซชันลงในการผสมผสาน และคุณยังมีการพึ่งพาอีก ITranslator (เพื่อสร้างข้อความอีเมลที่ถูกต้องในภาษาที่ผู้ใช้เลือก) การกระทำเหล่านี้หลายอย่างไม่จำเป็น โดยเฉพาะอย่างยิ่งแนวคิดในการเพิ่มการพึ่งพาจำนวนมากเหล่านี้และลงเอยด้วยตัวสร้างที่ไม่พอดีกับหน้าจอ ฉันพบตัวอย่างที่ดีในโค้ดเบสของ Magento (อีคอมเมิร์ซ CMS ยอดนิยมที่เขียนด้วย PHP) ในคลาสที่มีการพึ่งพา 32 รายการ!
บางครั้งมันก็ยากที่จะคิดออกว่าจะแยกตรรกะนี้อย่างไร และคลาสของ Magento ก็อาจตกเป็นเหยื่อของกรณีเหล่านี้ นั่นเป็นเหตุผลที่ฉันชอบวิธีที่ขับเคลื่อนด้วยเหตุการณ์:
namespace <base namespace>.Events { [Serializable] public class OrderCreated { private readonly Order order; public OrderCreated(Order order) { this.order = order; } public Order GetOrder() { return this.order; } } }
เมื่อใดก็ตามที่สร้างคำสั่งซื้อ แทนที่จะส่งอีเมลจากคลาส OrderService โดยตรง คลาสเหตุการณ์พิเศษ OrderCreated จะถูกสร้างขึ้นและเหตุการณ์จะถูกสร้างขึ้น ที่ใดที่หนึ่งในตัวจัดการเหตุการณ์ของแอปพลิเคชันจะได้รับการกำหนดค่า หนึ่งในนั้นจะส่งอีเมลไปยังลูกค้า
namespace <base namespace>.EventHandlers { public class OrderCreatedEmailSender : IEventHandler<OrderCreated> { public OrderCreatedEmailSender(IMailer, IUserParametersService, ITranslator) { // this class depend on all stuff which it need to send an email. } public void Handle(OrderCreated event) { this.mailer.Send(...); } } }
คลาส OrderCreated ถูกทำเครื่องหมายเป็น Serializable ตามวัตถุประสงค์ เราสามารถจัดการเหตุการณ์นี้ได้ทันที หรือจัดเก็บไว้ในคิว (Redis, ActiveMQ หรืออย่างอื่น) และประมวลผลในโปรเซส/เธรดแยกจากที่จัดการคำขอของเว็บ ในบทความนี้ ผู้เขียนอธิบายรายละเอียดว่าสถาปัตยกรรมที่ขับเคลื่อนด้วยเหตุการณ์คืออะไร (โปรดอย่าใส่ใจกับตรรกะทางธุรกิจภายใน OrderController)
บางคนอาจโต้แย้งว่าขณะนี้เป็นการยากที่จะเข้าใจว่าเกิดอะไรขึ้นเมื่อคุณสร้างคำสั่งซื้อ แต่นั่นไม่สามารถเพิ่มเติมจากความจริงได้ หากคุณรู้สึกเช่นนั้น เพียงใช้ประโยชน์จากฟังก์ชันการทำงานของ IDE ของคุณ โดยการค้นหาการใช้งานทั้งหมดของคลาส OrderCreated ใน IDE เราจะเห็นการดำเนินการทั้งหมดที่เกี่ยวข้องกับเหตุการณ์
แต่ฉันควรใช้ Dependency Injection เมื่อใด และฉันควรใช้แนวทางที่ขับเคลื่อนด้วยเหตุการณ์เมื่อใด ไม่ใช่เรื่องง่ายเสมอไปที่จะตอบคำถามนี้ แต่กฎง่ายๆ ข้อหนึ่งที่อาจช่วยคุณได้คือการใช้ Dependency Injection สำหรับกิจกรรมหลักทั้งหมดของคุณภายในแอปพลิเคชัน และวิธีการขับเคลื่อนด้วยเหตุการณ์สำหรับการดำเนินการรองทั้งหมด ตัวอย่างเช่น ใช้ Dependecy Injection กับสิ่งต่างๆ เช่น การสร้างคำสั่งซื้อภายในคลาส OrderService ด้วย IOrderRepository และมอบหมายการส่งอีเมล ซึ่งเป็นสิ่งที่ไม่ใช่ส่วนสำคัญของขั้นตอนการสร้างคำสั่งซื้อหลัก ไปยังตัวจัดการเหตุการณ์
บทสรุป
เราเริ่มต้นด้วยตัวควบคุมที่หนักมาก แค่คลาสเดียว และจบลงด้วยคอลเล็กชั่นคลาสที่ซับซ้อน ข้อดีของการเปลี่ยนแปลงเหล่านี้ค่อนข้างชัดเจนจากตัวอย่าง อย่างไรก็ตาม ยังมีหลายวิธีในการปรับปรุงตัวอย่างเหล่านี้ ตัวอย่างเช่น สามารถย้ายเมธอด OrderService.Create ไปยังคลาสของตัวเองได้: OrderCreator เนื่องจากการสร้างคำสั่งซื้อเป็นหน่วยอิสระของตรรกะทางธุรกิจตามหลักการความรับผิดชอบเดียว จึงเป็นเรื่องธรรมดาที่จะมีคลาสของตัวเองพร้อมชุดการพึ่งพาของตนเอง ในทำนองเดียวกัน การนำคำสั่งซื้อออกและการยกเลิกคำสั่งซื้อแต่ละรายการสามารถนำไปใช้ในชั้นเรียนของตนเองได้
เมื่อฉันเขียนโค้ดที่มีการควบคู่กันสูง ซึ่งคล้ายกับตัวอย่างแรกสุดในบทความนี้ การเปลี่ยนแปลงข้อกำหนดเพียงเล็กน้อยอาจนำไปสู่การเปลี่ยนแปลงในส่วนอื่นๆ ของโค้ดได้อย่างง่ายดาย SRP ช่วยให้นักพัฒนาเขียนโค้ดที่แยกจากกัน โดยที่แต่ละคลาสมีงานของตัวเอง หากข้อกำหนดของงานนี้เปลี่ยนแปลง ผู้พัฒนาจะทำการเปลี่ยนแปลงเฉพาะคลาสนั้นเท่านั้น การเปลี่ยนแปลงนี้มีโอกาสน้อยที่จะทำลายแอปพลิเคชันทั้งหมดเนื่องจากคลาสอื่น ๆ ควรจะยังคงทำงานเหมือนเดิม เว้นแต่แน่นอนว่าพวกเขาจะเสียตั้งแต่แรก
การพัฒนาโค้ดล่วงหน้าโดยใช้เทคนิคเหล่านี้และการปฏิบัติตามหลักการความรับผิดชอบเดียวอาจดูเหมือนเป็นงานที่น่ากลัว แต่ความพยายามจะได้ผลอย่างแน่นอนเมื่อโครงการเติบโตขึ้นและการพัฒนายังคงดำเนินต่อไป