27 คะแนน โดย GN⁺ 2025-04-24 | 4 ความคิดเห็น | แชร์ทาง WhatsApp
  • ภาษา Go ห้ามการอ้างอิงแบบวนซ้ำระหว่างแพ็กเกจอย่างเคร่งครัด จึงผลักดันให้เกิด การออกแบบแบบเป็นชั้น (layered design) โดยธรรมชาติ
  • บทความนี้อธิบาย โครงสร้างแบบเป็นชั้นที่โปรเจกต์ Go จำเป็นต้องมี และเสนอว่าเพียงเท่านี้ก็เพียงพอแล้วโดยไม่ต้องบังคับสถาปัตยกรรมอื่นเพิ่มเติม
  • เมื่อเกิดการพึ่งพาแบบวนซ้ำ บทความนำเสนอ กลยุทธ์การรีแฟกเตอร์ที่เป็นรูปธรรมและใช้งานได้จริง แบบเป็นลำดับขั้นเพื่อแก้ปัญหา
  • แต่ละแพ็กเกจถูกออกแบบให้มีหน่วยฟังก์ชันที่มีความหมายได้ด้วยตัวเอง จึงเอื้อต่อ การทดสอบ การบำรุงรักษา และการแยกเป็นไมโครเซอร์วิส
  • ท้ายที่สุด วิธีนี้ช่วยป้องกันปัญหาที่มักเกิดขึ้นในการออกแบบโค้ดจริงอย่าง "อยากได้แค่กล้วย แต่กลับต้องยกมาทั้งป่า"

แนวทางการออกแบบแบบ Layered ใน Go

หลักการพื้นฐาน

  • Go ห้ามการอ้างอิงแบบวนซ้ำระหว่างแพ็กเกจ
  • ความสัมพันธ์ import ของทุกโปรแกรม Go ต้องประกอบเป็น กราฟมีทิศทางแบบไม่มีวงจร (DAG)
  • โครงสร้างนี้ไม่ใช่ทางเลือก แต่เป็น กฎการออกแบบที่ถูกบังคับในระดับภาษา

การเกิดขึ้นโดยอัตโนมัติของการวางชั้นแพ็กเกจ

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

คุณลักษณะของแนวทางการออกแบบนี้

  • ชั้นต่าง ๆ ไม่ได้อิงกับการทำ abstraction แบบลำดับชั้น แต่ยึดตามทิศทางของการอ้างอิง
  • หนึ่งแพ็กเกจ สามารถอ้างอิงแพ็กเกจระดับล่างได้หลายตัว
  • แนวทางการออกแบบเดิมอย่าง MVC, Hexagonal Architecture เป็นต้น ก็สามารถ "นำมาปรับใช้" บนโครงสร้างนี้ได้
    → แต่ต้องคำนึงถึงข้อจำกัดเชิงโครงสร้างของ Go เสมอ

กลยุทธ์การแก้ปัญหาการอ้างอิงแบบวนซ้ำ

เมื่อเกิดการอ้างอิงแบบวนซ้ำ ให้ลองรีแฟกเตอร์ตามลำดับดังนี้:

1. ย้ายฟังก์ชัน

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

2. แยกฟังก์ชันที่ใช้ร่วมกันออกเป็นแพ็กเกจต่างหาก

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

3. สร้างแพ็กเกจระดับบนสำหรับการประกอบ

  • สร้าง แพ็กเกจที่สาม สำหรับประกอบสองแพ็กเกจที่เกิดวงจรเข้าด้วยกัน
  • ตัวอย่าง: แยกการพึ่งพาสองทางของ Category และ BlogPost ไปไว้ในแพ็กเกจระดับบน
    → ทำให้แพ็กเกจระดับล่างยังคงเป็น dumb struct ส่วนฟังก์ชันจริงอยู่ที่การประกอบในแพ็กเกจระดับบน

4. นำอินเทอร์เฟซมาใช้

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

5. คัดลอก (Copy)

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

6. รวมเป็นแพ็กเกจเดียว

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

ข้อดีเชิงปฏิบัติของแนวทางการออกแบบนี้

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

บทสรุป

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

4 ความคิดเห็น

 
bus710 2025-04-25

ตอนเริ่มเขียนแบบรีบ ๆ จนมันพอรันได้ก็ดูสนุกดี
แต่พอเริ่มใส่เทสต์เข้าไป
ก็จะเริ่มย้อนคิดว่าตอนนั้นฉันทำแบบนั้นไปทำไม

 
bungker 2025-04-24

คำว่า “อยากได้กล้วย แต่กลับยกทั้งป่ามาให้” นี่ตลกมากเลยนะ

 
iwanhae 2025-04-24

ตอนพัฒนา Spring หนึ่งในเรื่องที่ยากที่สุดน่าจะเป็นการวนลูปของ dependency..
ความอึดอัดของการที่มัน initializе กันไปมาไม่รู้จบจนพังเพราะ memory leak...

 
GN⁺ 2025-04-24
ความคิดเห็นบน Hacker News
  • การไม่อนุญาตให้มีการพึ่งพาแบบวนกลับเป็นตัวเลือกด้านการออกแบบที่ยอดเยี่ยมเมื่อสร้างโปรแกรมขนาดใหญ่

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

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

    • หากสร้างโครงสร้างโมเดลหลายแบบ (SQL, Protobuf, GraphQL เป็นต้น) ก็สามารถกำหนดทิศทางที่ชัดเจนระหว่างชั้นที่ถูกสร้างขึ้นได้
    • จัดให้โค้ดที่ถูกสร้างทั้งหมดเป็น "แพ็กเกจพื้นฐาน" สำหรับโค้ดแอปพลิเคชัน เพื่อใช้ประกอบทุกอย่างเข้าด้วยกัน
    • ก่อนใช้เทคนิคนี้ เคยมีปัญหา "โมเดล import โมเดลแบบวนกลับ" แต่หลังจากเพิ่มชั้นเชิงโครงสร้างเข้าไป ปัญหานี้ก็หายไปอย่างสิ้นเชิง
  • เหมือนกำลังอ่านหนังสือเกี่ยวกับวิธีการเชิงโครงสร้างของ Yourdon อยู่

  • แพ็กเกจไม่สามารถอ้างอิงกันแบบวนกลับได้

    • ที่จริงแล้ว ใน Go ทำได้ด้วยการใช้ go:linkname
  • ทำให้นึกถึงแนวคิดเฉพาะของ randomizer

  • จุดที่น่าสนใจของ Golang คือแม้จะมีการพึ่งพาแบบวนกลับในระดับแพ็กเกจไม่ได้ แต่ใน go.mod กลับทำได้

    • สรุปคือ อย่าทำแบบนั้นเหมือนกัน
  • เป็นคำอธิบายที่ยอดเยี่ยมว่า Jerf มองเรื่องแพ็กเกจและจัดการกับการพึ่งพาแบบวนกลับอย่างไร