เมื่อ Go กับ SOLID มาเจอกัน
การพัฒนาซอฟต์แวร์ในยุคปัจจุบัน หากเน้นการเร่งทำให้เสร็จเพื่อใช้งานก่อน แล้วค่อยกลับไปเก็บรายละเอียดทีหลัง (ซึ่งส่วนใหญ่ก็ไม่เคยทำได้หมด) มักจะส่งผลให้เมื่อต้องการเพิ่มฟีเจอร์ใหม่ ๆ จะเริ่มรู้สึกว่าการแก้ไขหรือเพิ่มเติมเป็นเรื่องยากลำบาก เพราะโค้ดที่เขียนขึ้นมาอาจจะเชื่อมโยงกันมากเกินไป หรือหากมีความต้องการใช้งานเพิ่มขึ้น ก็อาจจะไม่สามารถขยายระบบได้ตามความต้องการ เนื่องจากการพัฒนาซอฟต์แวร์นั้นขาดการวางแผนหรือโครงสร้างที่ชัดเจน
วันนี้เราจะมาแนะนำแนวทางในการพัฒนาซอฟต์แวร์ที่ช่วยให้โค้ดมีคุณภาพดี, ง่ายต่อการดูแลรักษา และสามารถขยายได้ตามความต้องการ แนวทางที่นิยมและเข้าใจง่ายคือ SOLID ซึ่งในตัวอย่างวันนี้จะใช้ภาษา Go เป็นหลัก (เพราะผมถนัด Go ครับ #ฮา)
มารู้จัก SOLID กันก่อน
SOLID เป็นแนวทางการพัฒนาซอฟต์แวร์เพื่อให้ได้โค้ดที่มีคุณภาพดี (ทำให้คนอ่านโค้ดรู้สึกสบายใจ), ง่ายต่อการดูแลรักษา (แก้ไขที่หนึ่งแล้วไม่ส่งผลกระทบไปยังส่วนอื่น ๆ ที่ไม่คาดคิด), และสามารถขยายได้ง่ายตามความต้องการที่เพิ่มขึ้น (สามารถต่อเติมได้โดยไม่ต้องกังวลว่าจะแก้ไขของเก่าพัง)
SOLID ประกอบด้วย
Single Responsibility Principle (SRP)
หลักการนี้หมายถึงโค้ดในแต่ละชุดควรมีความรับผิดชอบเพียงอย่างเดียว ไม่ทำงานหลายอย่างในเวลาเดียวกัน สิ่งนี้จะช่วยให้โค้ดสะอาดและบำรุงรักษาได้ง่าย เนื่องจากการเปลี่ยนแปลงโครงสร้างสามารถทำได้ในที่เดียว
ตัวอย่าง เริ่มจากเรามีโครงสร้างการรับชำระเงิน:
type Payment struct {}
func (k Payment) MakePayment() {
// do payment stuff
}
func (k Payment) CreateInvoice() {
// do invoice stuff
}
func (k Payment) SendBill() {
// do bill mailing stuff
}
จะเห็นว่า Payment
รับผิดชอบทั้งการชำระเงิน (Payment
), การสร้างใบแจ้งหนี้ (Invoice
), และการส่งบิล (Bill
) ฉะนั้นจาก SRP เราควรแยกให้โครงสร้างมีความรับผิดชอบเฉพาะตัวดังนี้:
type PaymentInfo struct {}
func (k PaymentInfo) MakePayment() {
// do payment stuff
}
type InvoiceInfo struct {}
func (k InvoiceInfo) CreateInvoice() {
// do invoice stuff
}
type Billing struct {}
func (k Billing) SendBill() {
// do bill mailing stuff
}
การแยกโครงสร้างเช่นนี้ทำให้โค้ดไม่มีการปะปนกันในการรับผิดชอบงานที่ไม่เกี่ยวข้อง นอกจากนี้ยังเห็นตัวอย่างที่ชัดเจนใน Go standard library
เช่น hash/crc64
และ hash/crc32
ที่แยก package ออกจากกัน แทนที่จะรวมกันใน hash/crc
Open-Closed Principle (OCP)
หลักการนี้หมายถึงโค้ดควรสามารถขยายได้โดยไม่ต้องแก้ไขโค้ดเดิม สิ่งนี้ช่วยให้โค้ดมีความยืดหยุ่นและปรับให้เข้ากับความต้องการที่เปลี่ยนแปลงได้โดยไม่ทำให้โค้ดที่มีอยู่เสียหาย
ตัวอย่าง เริ่มจากเรามีช่องทางการชำระเงินและกระบวนการชำระเงินที่ต้องใช้ Pay()
type PaymentChannel interface {
Pay()
}
type Payment struct {}
func (k Payment) Process(payCh PaymentChannel) {
payCh.Pay()
}
เมื่อเราต้องการเพิ่มช่องทางการชำระเงินใหม่ เช่น KKU PaymentHub:
type KkuPayment struct {
amount float64
}
func (kkupay KkuPayment) Pay() {
fmt.Printf("Paid %.2f via KKU PaymentHub", kkupay.amount)
}
func main() {
payment := Payment{}
kkuPay := KkuPayment{12.23}
payment.Process(kkuPay)
}
ต่อมา หากเราต้องการให้สามารถชำระผ่านบัตรเครดิตได้ เราสามารถทำได้แบบนี้:
type CreditCard struct {
amount float64
}
func (cc CreditCard) Pay() {
fmt.Printf("Paid %.2f via CreditCard", cc.amount)
}
func main() {
payment := Payment{}
// KKU PaymentHub
kkuPay := KkuPayment{60.43}
payment.Process(kkuPay)
// Credit Card
creditPay := CreditCard{11.12}
payment.Process(creditPay)
}
จะเห็นว่าเราสามารถใช้ Process()
ร่วมกันได้ทั้ง KkuPayment
และ CreditCard
รวมถึงช่องทางการชำระเงินที่จะเพิ่มเติมในอนาคต โดยไม่ต้องปรับเปลี่ยนโค้ดที่ทำงานอยู่แล้ว
Liskov Substitution Principle (LSP)
หลักการนี้หมายถึงอ็อบเจกต์ของ super class ควรสามารถแทนที่ด้วยอ็อบเจ็กต์ของ sub class โดยไม่กระทบต่อความถูกต้องของโปรแกรม สิ่งนี้ช่วยให้แน่ใจว่าความสัมพันธ์ระหว่างคลาสนั้นชัดเจนและสามารถรักษาโครงสร้างเดิมไว้ได้
ตัวอย่าง เรามีกาแฟรสชาติขม:
type Coffee struct {}
func (c Coffee) Taste() {
fmt.Println("Bitter")
}
ทีนี้เรามีลาเต้ซึ่งเป็นกาแฟที่รสชาติหวาน:
type Late struct {
Coffee
}
func (a Late) Taste() {
fmt.Println("Sweet")
}
ตามหลัก LSP เราสามารถเขียนทับฟังก์ชันของ super class ได้โดยไม่กระทบกับระบบเดิม:
type CoffeeTaste interface {
Taste()
}
func TasteOf(cTaste CoffeeTaste) {
cTaste.Taste()
}
coffee := Coffee{}
late := Late{}
TasteOf(coffee) // Bitter
TasteOf(late) // Sweet
จะเห็นว่า TasteOf
ที่สร้างขึ้นเป็นตัวกลางสามารถรับพารามิเตอร์ที่เป็น CoffeeTaste
ได้ ทำให้ Coffee
และ sub class อื่น ๆ ที่ขยายมาจาก Coffee
สามารถทำงานได้ในฟังก์ชันเดียวกัน เนื่องจากมีฟังก์ชัน Taste()
เหมือนกัน และรับรองว่าตัวแปรที่จะเข้ามาในฟังก์ชันนี้ต้องมีฟังก์ชัน Taste()
เสมอ
Interface Segregation Principle (ISP)
หลักการนี้หมายถึงอินเทอร์เฟซควรได้รับการออกแบบให้มีขนาดเล็กและเฉพาะเจาะจงมากที่สุดเท่าที่จะเป็นไปได้ สิ่งนี้ช่วยให้โค้ดมีความยืดหยุ่นและหลีกเลี่ยงความสัมพันธ์ระหว่างคลาสโดยไม่จำเป็น
ตัวอย่าง เรามีอินเทอร์เฟซที่ดูแลด้านการสั่งซื้อ:
type Order interface {
GetOrder()
CreateOrder()
GetItems()
AddItems()
Pay()
}
จะเห็นว่ามีงานหลากหลายอยู่ในอินเทอร์เฟซเดียวกัน จากนิยาม ISP เราจะแบ่งออกเป็น:
type Order interface {
GetOrder()
CreateOrder()
}
type OrderItem interface {
GetItems()
AddItems()
}
type Payment interface {
Pay()
}
การแยกออกเป็นอินเทอร์เฟซที่รับผิดชอบงานของตัวเองจะช่วยให้การแก้ไขหรือติดตามปัญหาทำได้ง่ายขึ้น
Dependency Inversion Principle (DIP)
หลักการนี้ระบุว่าโมดูลระดับบนไม่ควรขึ้นอยู่กับโมดูลระดับล่างโดยตรง แต่ทั้งสองอย่างควรขึ้นอยู่กับสิ่งที่เป็นตัวกลางร่วมกัน สิ่งนี้ช่วยลดความสัมพันธ์ระหว่างส่วนประกอบต่าง ๆ และทำให้โค้ดมีความยืดหยุ่นและบำรุงรักษาได้มากขึ้น
ตัวอย่าง เมนูร้านน้ำแห่งหนึ่งมีทั้งชาและกาแฟอยู่ในเมนู:
type Menu struct {
TeaList []Tea
CoffeeList []Coffee
}
จะเห็นว่า Menu
ขึ้นตรงกับโครงสร้างของ Tea
และ Coffee
เมื่อต้องการเพิ่มประเภทใหม่หรือมีการแก้ไข Tea
หรือ Coffee
ก็จะทำให้โครงสร้างของ Menu
เปลี่ยนไป จากนิยามของ DIP เราจะเปลี่ยนได้ด้วยการใช้ Interface ร่วมกันคือ Drink
:
type Menu struct {
Drinks []Drink
}
type Drink interface {
GetCategory() string
GetName() string
GetPrice() float64
}
type Coffee struct {
Category string
Name string
AddOn float64
Price float64
}
func (c Coffee) GetCategory() string {
return c.Category
}
func (c Coffee) GetName() string {
return c.Name
}
func (c Coffee) GetPrice() float64 {
return c.Price + c.AddOn
}
type Tea struct {
Category string
Name string
Price float64
}
func (t Tea) GetCategory() string {
return t.Category
}
func (t Tea) GetName() string {
return t.Name
}
func (t Tea) GetPrice() float64 {
return t.Price
}
จะเห็นได้ว่าเมื่อใช้แนวทางนี้ ไม่ว่า Tea
หรือ Coffee
จะมีโครงสร้างภายในแตกต่างกันหรือเปลี่ยนไปอย่างไร ก็ยังสามารถจัดเก็บไว้ใน Menu
ได้โดยไม่กระทบกัน เพราะทั้งคู่ยังคงเป็น Drink
อยู่
สรุป
SOLID เป็นแนวทางหนึ่งในการพัฒนาซอฟต์แวร์เพื่อไม่ให้เราหลงทางไปสู่วังวนของ Technical Debt และยังมีแนวทางอื่น ๆ ที่ใช้กันอย่างแพร่หลาย หากสนใจสามารถติดตามบทความถัดไปได้ สวัสดีครับ 🙏