สร้าง API ให้ใช้งานง่ายด้วยหลักการของ Functional Options
การออกแบบ API หรือฟังก์ชันที่มีการรับพารามิเตอร์หลายค่านั้นควรมีการออกแบบอย่างไรจึงจะเข้าใจได้ง่าย สะดวกต่อการใช้งาน และแปลงเป็นเอกสารประกอบโค้ดแล้วไม่ดูอุจาดตา
สมมติเราต้องการออกแบบ API สำหรับข้อมูลบ้านในโฉนดที่ดินภายใต้ชื่อของ NewHouse
โดยกำหนดให้บ้านทุกหลังจำเป็นต้องมีเลขที่บ้าน/ที่อยู่ ฟังก์ชัน NewHouse
จึงมีหน้าตาแบบนี้
1type House struct {23}45func NewHouse(address string) *House
บ้านนั้นไม่ใช่แค่ที่ดินนะ จะได้มีแค่เลขที่บ้านแล้วจบ มันควรจะมีข้อมูลอื่นด้วยซิ เช่น จำนวนประตู จำนวนชั้น เป็นต้น หากเราต้องการสร้างบ้านด้วยข้อมูลที่เพิ่มมานี้ ฟังก์ชัน NewHouse
ที่ได้ใหม่ก็จะเปลี่ยนไป
1func NewHouse(address string, doors uint, floors uint) *House
แม้เด็กอนุบาลก็ยังทราบ เมื่อกำหนดให้ฟังก์ชันสามารถรับอาร์กิวเมนต์ได้หลายค่าย่อมเป็นภาระกับผู้เรียกใช้ จะเรียก NewHouse
ซักครั้งต้องมานั่งกังวลกับการใส่ลำดับของ address, doors หรือ floors ให้ถูกต้อง
แถมกลับมาอ่านอีกครั้งก็ยังเข้าใจยากอีกว่าแต่ละตำแหน่งที่ส่งไปในฟังก์ชันคือค่าของสิ่งใด
1// 3 คือค่าของ doors หรือ floors? แล้ว 4 ละ?2NewHouse("111/11", 3, 4)
การออกแบบ API ที่ดีควรสร้างให้ฟังก์ชัน NewHouse
เป็นอย่างไรกันแน่ ถึงจะง่ายต่อการอ่านและใช้งาน? เราจะแก้ไขปัญหานี้กับหลักการของ Functional Options
ปัญหาของการสร้าง Configuration Struct
แน่นอนว่า NewHouse
ของเราอาจไม่ได้มีแค่ address, doors หรือ floors ที่สามารถระบุค่าได้ หากแต่ยังต้องมี ชื่อบ้าน (name), ขนาดที่ดิน (area), เจ้าของ (owner) และค่าอื่น ๆ อีกมาก
ถ้าจะให้ระบุค่าเหล่านี้เรียงตับตอนส่งผ่านฟังก์ชัน NewHouse
มันต้องลำบากเป็นแน่
เอาใหม่ ๆ เราทราบอยู่แล้วว่า address ทุกบ้านนั้นต้องมีก็ให้ระบุเป็นพารามิเตอร์ตัวแรกเช่นเดิม เพิ่มเติมคือค่าอื่น ๆ ให้โยนลงไปใน struct ชื่อ Config
แทน
จากนั้นจึงทำการสร้างตัวแปรจาก struct นี้พร้อมกำหนดค่าแล้วจึงส่งเป็นพารามิเตอร์ตัวที่สองของ NewHouse
1type Config struct {2 Doors uint3 Floors uint4 Name string5 Area float326 Owner string7}89func NewHouse(address string, config *Config) *House
ตัวอย่างการเรียกใช้ NewHouse
เช่น
1house := NewHouse("111/11", &Config{2 Doors: 3,3 Floors: 4,4 Name: "Somchai's House",5 Area: 59.99,6 Owner: "Somchai",7})
วิธีการนี้ก็ดูเหมือนจะดีกว่าการไล่ส่งค่าทีละตัวลงฟังก์ชัน แต่จุดบอดของวิธีนี้ยังพบได้อยู่ 3 อย่าง
สิ่งแรกคือทุกครั้งที่ต้องการเพิ่มค่าสำหรับการสร้างบ้านก็ไม่พ้นที่จะต้องแก้ไข Config
นั่นทำให้ struct นี้นับวันมีแต่จะใหญ่ขึ้นเรื่อย ๆ
ถัดมาคือหากเราต้องการกำหนดให้ house แทนบ้านของหมู่บ้านหนึ่งซึ่งแน่นอนว่าส่วนใหญ่จะมีจำนวนที่ดิน ประตูและชั้นเท่า ๆ กัน
เราจึงต้องการให้การสร้าง house ผ่าน NewHouse
ไม่ต้องทำการระบุค่า Config
เมื่อไม่ระบุค่านี้ขอให้ใช้ค่า default ของแบบบ้านในหมู่บ้านนี้ อย่างไรก็ตามเราไม่สามารถละการส่งค่า Config
ไปได้
เนื่องจากฟังก์ชัน NewHouse
ต้องระบุค่า Config
ลงไปเป็นอาร์กิวเมนต์ตัวที่สองเสมอ สิ่งที่เราทำได้จึงเป็นการส่ง nil
ไปแทน เพื่อบอก NewHouse
ให้ใช้ค่า default
1house := NewHouse("111/11", nil)
บอกเลยว่าการส่งค่า nil ไปแบบนี้อย่าหาทำ เราจะรู้สึกสบายใจกว่าหากเราสามารถออกแบบฟังก์ชันของเราให้ไม่ต้องส่งค่า nil ที่ไร้ความหมายไปเช่นนี้
1house := NewHouse("111/11")
ประการสุดท้ายเราจะสังเกตได้ว่าในหลาย ๆ กรณีชื่อของบ้าน (name) มักใช้ชื่อเป็นชื่อของเจ้าของบ้าน (owner) แทน เมื่อเป็นเช่นนี้เราจึงต้องการให้ Config
อนุญาตให้เราละเว้นการส่ง name ได้
เมื่อไหร่ที่ไม่มีค่า name ส่งมาให้ถือเอาชื่อเจ้าของบ้านเป็นชื่อบ้านแทน
1// คาดหวังว่า name จะเป็น Somchai's House2house := NewHouse("111/11", &Config{3 Doors: 3,4 Floors: 4,5 Area: 59.99,6 Owner: "Somchai"7})
โดยทั่วไปโค้ดของ NewHouse
อาจเป็นเพียงการกำหนดค่าจาก Config
ใส่ลงไปเพื่อสร้าง house ก็ได้
1func NewHouse(address string, config *Config) *House {2 house := House{3 Address: address,4 Doors: config.Doors,5 // ค่าอื่น ๆ จาก config6 }78 if house.Name == "" {9 house.Name = config.Owner + " 's House"10 }1112 return &house13}
ตามหลักการ Zero Values ในภาษา Go เมื่อเราไม่ระบุค่า name ผ่าน Config
name จึงมีค่าเป็น "" ไม่ใช่ค่า nil
ครั้นเราจะตรวจสอบว่า name มีค่าเป็น "" หรือไม่ หากมีค่าเป็น "" ให้กำหนดค่า name เป็นชื่อเจ้าของ เช่นนี้ก็ทำไม่ได้
นั่นเพราะกรณีที่บ้านยังไม่ถูกขายส่วนของ Config ต้องระบุค่า name เป็นค่า "" เข้ามาตรง ๆ เพื่อเป็นการบอกให้บ้านนี้ไม่มีชื่อ (ไม่ใช่ให้ตั้งค่า default เป็นชื่อของเจ้าของบ้าน)
แต่เงื่อนไขบรรทัดที่ 8 ของเราดันเช็คค่า Name จาก "" ชื่อของบ้านจึงกลายเป็น 's House'
แทน
แก้ปัญหาการส่ง nil ด้วย Variadic Functions
ปัญหาการส่ง nil เพื่อบอกให้ฟังก์ชันใช้ค่า default แทนนั้นสามารถแก้ได้ด้วย Variadic Functions
1house := NewHouse("111/11", nil)
Variadic Functions นั้นทำให้เราสามารถรับค่าพารามิเตอร์เข้ามาในฟังก์ชันกี่ตัวก็ได้ นั่นรวมถึงทำให้เราละการส่งค่าเข้ามาในฟังก์ชันก็ได้เช่นกัน นี่จึงทำให้เราหลีกเลี่ยงการส่งค่า nil เข้าไปในฟังก์ชันได้
1func NewHouse(address string, config ...*Config) *House23// จะส่งแบบนี้4house := NewHouse("111/11")56// หรือส่งแบบนี้ก็ย่อมได้7house := NewHouse("111/11", &Config{8 Doors: 3,9 Floors: 4,10 Name: "Somchai's House",11 Area: 59.99,12 Owner: "Somchai",13})
Functional Options คืออะไร
ย้อนกลับไปดูปัญหาแรกที่บอกว่าการมี Config เดียวทำให้ทุกครั้งที่ต้องการเพิ่มออฟชั่นให้กับบ้านต้องกระทำผ่าน Config เสมอ ทำให้ struct นี้นับวันมีแต่จะใหญ่ขึ้น หากเราจะแก้ปัญหานี้ด้วยการแยกเป็น struct ย่อย ๆ คือ struct สำหรับการตั้งค่า Doors ในชื่อของ DoorsConfig หรือ struct ชื่อ FloorsConfig สำหรับการตั้งค่า Floors ก็ย่อยทำได้ เพียงแต่ปัญหาสุดท้ายที่เราทิ้งไว้คือการกำหนดค่า default (เช่น ไม่ระบุค่า name ให้ใช้ชื่อของ owner แทน) ก็ยังไม่หมดไปอยู่ดี
เพื่อให้ยืดหยุ่นเพียงพอ เราควรสร้างการตั้งค่าเป็นฟังก์ชันแทนจะดีกว่า นั่นเพราะเมื่อเป็นฟังก์ชันเราจะสามารถแยกย่อยการตั้งค่าออกจากกันเป็นส่วน ๆ ได้ อีกทั้งฟังก์ชันอนุญาตให้เรามีโค้ดการทำงานอื่นเพิ่มเติมได้ เราจึงสามารถกำหนดค่าเริ่มต้นของ Name ไว้ได้เช่นกัน
1type House struct {2 Address string3 Doors uint4 Floors uint5 Name string6 Area float327 Owner string8}910type HouseOption func(*House)1112func NewHouse(address string, options ...HouseOption) *House1314func WithFloors(floors uint) HouseOption {15 return func(h *House) {16 h.Floors = floors17 }18}1920// ฟังก์ชันสำหรับการตั้งค่าอื่น ๆ กระทำเหมือนกัน2122// เรียกใช้งาน23func main() {24 house := NewHouse("111/11", WithFloors(3))25}
จากบรรทัดที่ 24 พบว่าเราไม่มี struct ชื่อ Config
อีกต่อไป แต่เราทำการสร้างฟังก์ชันชื่อ WithFloors
สำหรับการตั้งค่าจำนวนชั้นขึ้นมาแทน
ฟังก์ชันนี้จะทำการคืนฟังก์ชันที่รับค่า *House เข้ามาเพื่อใช้ในการเปลี่ยนแปลงค่า Floors ของบ้าน (บรรทัดที่ 16) อีกทีนึง
กรณีของค่าอื่นที่ไม่ได้ระบุ ให้ฟังก์ชัน NewHouse
เป็นผู้จัดการกำหนดค่า default ให้เอง
ด้วยความที่ฟังก์ชัน WithFloors
เป็นตัวกำหนดค่าของออฟชั่น (ในที่นี้คือจำนวนชั้น) เราจึงเรียกเทคนิคนี้ว่า Functional Options
เพื่อให้ NewHouse
สามารถกำหนดค่า default และนำค่าจาก HouseOption
ต่าง ๆ เช่น Floors มาใช้งานได้ เราจึงทำการเขียนโปรแกรมดังต่อไปนี้
1func NewHouse(address string, options ...HouseOption) *House {2 // กำหนดค่าเริ่มต้น3 house := House{4 Address: address,5 Doors: 3,6 Floors: 3,7 }89 for _, option := range options {10 // กำหนดออฟชั่นด้วยการส่ง house ไปให้11 option(&house)12 }1314 return &house15}
บรรทัดที่ 9-12 เราทำการวนลูปเพื่อส่งค่าของ house เข้าไปใช้ในการกำหนดออฟชั่น เช่นส่งเข้าไปในฟังก์ชันที่คืนจาก WithFloors
เพื่อกำหนดค่าจำนวนชั้น เป็นต้น
ต่อไปเราจะสร้างฟังก์ชัน WithOwner
สำหรับกำหนดค่าของ name ให้กับบ้าน หากไม่มีการระบุค่าของ name เข้ามาให้ถือเอาชื่อของเจ้าของบ้านมาเป็นชื่อบ้านแทน
1func WithOwner(owner string) HouseOption {2 return func(h *House) {3 h.Owner = owner45 // หากบ้านนี้ไม่มีชื่อ ให้กำหนดค่าเริ่มต้นเป็นชื่อของเจ้าของบ้าน6 if h.Name == "" {7 h.Name = h.Owner + "'s House"8 }9 }10}
การสร้างออฟชั่นผ่านฟังก์ชันนั้นยืดหยุ่นกว่า เราจึงสามารถแยกการทำงานที่ซับซ้อนออกไปในแต่ละฟังก์ชันได้แทนที่จะไปเขียนรวมกันใน NewHouse
กรณีของ Name ขึ้นตรงอยู่กับชื่อของเจ้าของบ้าน เราจึงย้ายส่วนการกำหนดชื่อนี้ไปไว้ที่ WithOwner
ปัญหาของการกำหนดชื่อที่เคยพูดถึงมาก็จะหมดไป
นั้นเพราะเราจะเรียกใช้ WithOwner
กรณีที่มีเจ้าของบ้านแล้วเท่านั้น หากยังไม่มีเจ้าของบ้านก็จะไม่เรียก เป็นผลทำให้ชื่อของบ้านซึ่งถูกกำหนดใน WithOwner
ไม่ถูกตั้งค่าตามไปด้วย
1func main() {2 // ยังไม่มีเจ้าของบ้าน ชื่อจะยังไม่ถูกตั้งด้วยเช่นกัน3 NewHouse("111/11")45 // มีเจ้าของบ้านแล้ว ชื่อจะถูกตั้งตามเจ้าของบ้าน6 NewHouse("111/12", WithOwner("Somchai"))7}
การจัดการ Options ที่ซับซ้อนด้วย Functional Options
แม้ว่าการกำหนดค่าออฟชั่นผ่าน struct อย่าง Config
จะมีรูปแบบการประกาศและใช้งานที่ง่ายกว่าในกรณีที่มีออฟชั่นน้อย
แต่ Functional Options นั้นยืดหยุ่นกว่าในแง่ของการทำงานกับออฟชั่นที่ซับซ้อน
สมมติให้ Owner ของบ้านไม่เก็บค่าเป็น string อีกต่อไป หากแต่เก็บเป็นค่าจาก struct คือ Owner
เราสามารถทำการสร้างและกำหนดค่าจาก struct ดังกล่าว พร้อมกำหนดค่าของ owner และค่าเริ่มต้นของชื่อบ้านผ่าน WithOwner
ได้เช่นเดิม ดังนี้
1type Owner struct {2 Name string3 Age uint4}56func WithOwner(name string, age uint) HouseOption {7 return func(h *House) {8 h.Owner = &HouseOwner{Name: name, Age: age}910 if h.Name == "" {11 h.Name = h.Owner.Name + "'s House"12 }13 }14}1516// เรียกใช้งาน17func main() {18 house := NewHouse(19 "111/11",20 WithOwner(&Owner{ name: "Somchai", age: 24 }),21 )22}
หากเราไม่ได้ใช้ Functional Options แต่ใช้ struct คือ Config
แทน การกำหนดค่าเริ่มต้นที่ซับซ้อนของ name
จะต้องผลักภาระไปให้ NewHouse
แทน นั่นแปลว่า NewHouse
จะกลายเป็นฟังก์ชันที่มีอีกหนึ่งหน้าที่คือต้องจัดการออฟชั่นด้วยนั่นเอง
1type Config struct {2 Doors uint3 Floors uint4 Name string5 Area float326 Owner *Owner7}89func NewHouse(address string, config *Config) *House {10 // กำหนดค่าเริ่มต้น11 house := House{12 Address: address,13 // กำหนดค่าอื่น ๆ จาก config14 }1516 // เขียนโค้ดเพื่อเพิ่ม config.Owner.Name ให้เป็นค่าของ house.Name17 // ในกรณีที่ house.Name ไม่มีค่า1819 return &house20}2122// เรียกใช้งาน23func main() {24 house := NewHouse(25 "111/11",26 &Config{Owner: &Owner{ name: "Somchai", age: 24 }},27 )28}
รูปแบบของการกำหนด Presets
ฟังก์ชัน NewHouse
ข้างต้นพบว่ามีการกำหนดค่าเริ่มต้นของบ้านไว้ภายใต้ตัวมันเอง
1func NewHouse(address string, options ...HouseOption) *House {2 // กำหนดค่าเริ่มต้น3 house := House{4 Address: address,5 Doors: 3,6 Floors: 3,7 }89 for _, option := range options {10 option(&house)11 }1213 return &house14}
เพื่อให้เกิดความยืดหยุ่นมากขึ้นเราจึงแยกโค้ดดังกล่าวออกมาเป็นค่าเริ่มต้นภายใต้ชื่อ DefaultPreset
1var DefaultPreset = []HouseOption{WithFloors(3), WithDoors(3)}23func NewHouse(address string, options ...HouseOption) *House {4 house := House{5 Address: address,6 }78 // กำหนดค่าเริ่มต้นจาก DefaultPreset9 options = append([]HouseOption{DefaultPreset}, options...)1011 for _, option := range options {12 option(&house)13 }1415 return &house16}
[]HouseOption
จากบรรทัดที่ 1 และ 9 ทำให้โค้ดของเราดูอ่านยาก เราจึงควรสร้างฟังก์ชัน Options
เพื่อดำเนินการกับออฟชั่นเหล่านี้แทนที่จะมาประกาศเป็น slice
1func Options(options ...HouseOption) HouseOption {2 return func(h *House) {3 for _, option := range options {4 option(h)5 }6 }7}89var DefaultPreset = Options(WithFloors(3), WithDoors(3))1011func NewHouse(address string, options ...HouseOption) *House {12 house := House{13 Address: address,14 }1516 // กำหนดค่าเริ่มต้นจาก DefaultPreset17 DefaultPreset(&house)1819 for _, option := range options {20 option(&house)21 }2223 return &house24}
ตัวแปร DefaultPreset
ที่เราสร้างไว้ล่วงหน้าเพื่อการเรียกใช้งานนี้เราเรียกว่า Presets เราสามารถใช้ Presets เพื่อกำหนดกลุ่มของออฟชั่นไว้ก่อนได้
เช่น เราทราบอยู่แล้วว่าคอนโดทั่วไปมักมี 1 ชั้น 3 ประตู เราจึงสร้าง CondoPreset
ขึ้นมาดังนี้
1var CondoPreset = Options(2 WithFloors(1),3 WithDoors(3),4)56// เรียกใช้งาน7func main() {8 house := NewHouse(9 "111/11",10 CondoPreset,11 WithOwner("Somchai", 24),12 )13}
บรรทัดที่ 10 เราทำการส่ง CondoPreset
ไปยังฟังก์ชัน เป็นผลให้เกิดการตั้งค่าชั้นเป็น 1 และจำนวนประตูเป็น 3
ผู้อ่านจะสังเกตเห็นได้ว่าเรายังสามารถกำหนดค่าอื่นเป็นออฟชั่นเพิ่มได้อีก เช่น บรรทัดที่ 11 ที่เป็นการเพิ่ม Owner ให้กับบ้านดังกล่าว
รวมกลุ่ม API ด้วย Package
เพื่อให้การสร้างบ้านของเรามีความเป็น API มากขึ้น เราจึงรวมกลุ่มของสิ่งที่สร้างภายใต้แพคเกจคือ house
1package house23type HouseOwner struct {4 Name string5 Age uint6}78type House struct {9 Address string10 Doors uint11 Floors uint12 Name string13 Area float3214 Owner *HouseOwner15}1617type HouseOption func(*House)1819func Options(options ...HouseOption) HouseOption {20 return func(h *House) {21 for _, option := range options {22 option(h)23 }24 }25}2627func Floors(floors uint) HouseOption {28 return func(h *House) {29 h.Floors = floors30 }31}3233func Doors(floors uint) HouseOption {34 return func(h *House) {35 h.Floors = floors36 }37}3839func Owner(name string, age uint) HouseOption {40 return func(h *House) {41 h.Owner = &HouseOwner{Name: name, Age: age}4243 if h.Name == "" {44 h.Name = h.Owner.Name + "'s House"45 }46 }47}4849var (50 DefaultPreset = Options(Floors(3), Doors(3))51 CondoPreset = Options(Floors(1), Doors(3))52)5354func New(address string, options ...HouseOption) *House {55 house := House{56 Address: address,57 }5859 DefaultPreset(&house)6061 for _, option := range options {62 option(&house)63 }6465 return &house66}
ส่วนของการเรียกใช้งานใหม่เป็นดังนี้
1package main23import "house"45func main() {6 house.New("111/11", house.CondoPreset, house.Owner("Somchai", 24))7}
Functional Options กับการสร้างเอกสาร
นอกจากการใช้ Functional Options จะช่วยให้ API ของเรายืดหยุ่นต่อการเรียกใช้งานแล้ว
เวลาเราใช้ godoc
ในการสร้างเอกสาร ภาษา go จะทำการรวมกลุ่มออฟชั่นเข้าด้วยกันทำให้การอ่านเอกสารประกอบการใช้งานนั้นง่ายขึ้น
เมื่อไหร่ควรใช้ Functional Options
โดยทั่วไปการระบุค่าออฟชั่นผ่าน struct เช่น Config
ก็เพียงพอแล้ว แต่เมื่อใดที่เราต้องการละเว้นการระบุออฟชั่นบางค่า
แต่ไม่ต้องการให้ API เข้าใจว่าออฟชั่นดังกล่าวมีค่าเป็น zero values ของมัน Functional Options
จะช่วยแก้ไขปัญหานี้
กำหนดให้แพคเกจ server มี Config ที่สามารถระบุค่า Timeout และ Port เป็นออฟชั่นตอนสร้างเซิฟเวอร์
1package server23type Config struct {4 Timeout time.Duration5 Port int6}78func new(addr string, config *Config) *Server
เมื่อเราทำการเรียกใช้งานโดยระบุเพียง Timeout ลงไปใน Config จะทำให้ Port มีค่าเป็น 0 ตามหลักการ zero values แม้ความเป็นจริงค่า 0 ของ Port ควรหมายถึงให้ทำการสุ่มพอร์ตก็ตาม แต่ API จะแยกแยะไม่ได้ว่า 0 ที่ส่งผ่าน Config นั้นเป็นค่าที่ผู้เรียกใช้งานระบุเข้ามา หรือเป็นเพียง zero values ของตัว Port เอง
1server.New("localhost", &server.Config{Timeout: 100 * time.Second})
ปัญหาดังกล่าวแก้ได้ด้วย Functional Options ตามที่กล่าวมาแล้ว นอกจากนี้ Functional Options ยังเหมาะกับการแยกความซับซ้อนของแต่ละออฟชั่นออกจากกันผ่านการแบ่งแยกฟังก์ชันอีกด้วย
เอกสารอ้างอิง
Dave Cheney (2014). Functional options for friendly APIs. Retrieved July, 14, 2020, from https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis
Soham Kamani (2019). Functional Options in Go: Implementing the Options Pattern in Golang. Retrieved July, 14, 2020, from https://www.sohamkamani.com/golang/options-pattern/
Márk Sági-Kazár (2020). Functional options on steroids. Retrieved July, 14, 2020, from https://sagikazarmark.hu/blog/functional-options-on-steroids/
Rob Pike (2014). Self-referential functions and the design of options. Retrieved July, 14, 2020, from https://commandcenter.blogspot.com/2014/01/self-referential-functions-and-design.html
สารบัญ
- ปัญหาของการสร้าง Configuration Struct
- แก้ปัญหาการส่ง nil ด้วย Variadic Functions
- Functional Options คืออะไร
- การจัดการ Options ที่ซับซ้อนด้วย Functional Options
- รูปแบบของการกำหนด Presets
- รวมกลุ่ม API ด้วย Package
- Functional Options กับการสร้างเอกสาร
- เมื่อไหร่ควรใช้ Functional Options
- เอกสารอ้างอิง