ก้าวออกจาก Node.js มาเขียน Golang เป็นอาชีพ
จากการเขียน node.js มายาวนาน ได้ลองย้ายมาจับโกมีอะไรไม่ชินหลายอย่างมาก มาดูกันว่าจะเริ่มยังไง และมีอะไรเปลี่ยนไปในชีวิตบ้าง
Wed May 01 2019
muitsfriday.dev
จากการเขียน node.js มายาวนาน ได้ลองย้ายมาจับโกมีอะไรไม่ชินหลายอย่างมาก มาดูกันว่าจะเริ่มยังไง และมีอะไรเปลี่ยนไปในชีวิตบ้าง
Wed May 01 2019
ผมเป็นคนที่ผูกพันธ์กับ javscript มานานมาก เรียกได้ว่าตั้งแต่การทำงานแรกๆ ผมก็จับ javscript มาเกือบตลอด สมัยก่อนยังไม่มี node.js (หรือจริงๆมีแล้วแต่ผมยังไม่รู้จักนะ) ตอนนั้น javscript ก็ใช้งานอยู่ในฝั่ง front-end ของหน้าเว็บแล้ว
พอหลังจากมี node.js งานที่ผมทำก็ได้เอามาใช้ทำพวก background service ต่างๆ (ภาษา backend หลักๆยังเป็น PHP อยู่ตอนนั้น) และ front-end บางส่วนก็มีการเอา react มาใช้
แล้วช่วงไม่นานมานี่ผมลาจาก PHP กลายร่างมาเป็น backend node.js อย่างเต็มที่ ปีกว่าที่ทำแต่ node.js จนเรียกได้ว่าชินมากๆ ซึ่งผมก็ชอบภาษานี้พอควรด้วยถึงแม้จะมีจุดที่ dirty เยอะอยู่พอควร
แต่แล้ว… ผมก็ต้องเข็นตัวเองออกจาก javascript มาสู่ดินแดนที่ไม่รู้แห่ง golang
Golang เป็นภาษาที่ถูกเอามาใช้ในหลายๆ บริษัทช่วงหลังมานี่ด้วยความที่เขาเคลมว่ามัน ✌เร็ว✌ เหมาะกับการเอามาทำ back-end service มากๆ จากที่ผมเคยจับภาษานี้เล่นเฉยๆ กลายเป็นว่าผมต้องทำมันเป็นอาชีพเสียแล้ว
ผมเลยตัดสินใจเขียนบล็อกนี้เพื่อบันทึกความ ปวดหัว เปลี่ยนแปลง ที่ผมมีเมื่อผมต้องมาจับภาษานี้แทน
(Disclaimer: ผมมือใหม่มากถ้าตรงไหนเข้าใจผิดรบกวนแก้ไขชี้แนะผมด้วยในคอมเมนต์นะครับ กราบบ)
ตรงระบบนี้ที่มองว่ากลุ่มก้อนโค้ดที่เราจะเขียนคือ module(โมดูล) นึง เราสร้างมันเพื่อทำหน้าที่จำเพาะอะไรสักอย่าง แล้วเราก็ยังสามารถแบ่งปันโมดูลที่เราสร้างให้คนอื่นๆใช้ได้ด้วย คอนเซ็ปต์จะคล้ายๆ node.js เลยฃ
เพื่อการแชร์โค้ดที่เราเขียนให้ชาวโลกใช้เราจะทำการอัปโหลดโค้ดของเราขึ้นไปที่ repository ถ้าเป็น node.js เราจะใช้สุดยอด repository ที่นิยมสุดๆคือ https://www.npmjs.com/
ตรงนี้จะต่างจากใน golang นิดหน่อยคือ เราแค่อัปโหลดโค้ดขึ้นไปใน git repository เจ้าไหนก็ได้(นิยมสุดก็ github อะเนอะ) เมื่อมีไครสนใจจะใช้โมดูลที่เราเขียน เขาก็แค่ระบุที่อยู่ของ repository แล้วตัว go จะทำการดึงโค้ดมาลงเครื่องให้เองผ่านคำสั่ง git clone
ส่วนโมดูลที่เราอยากใช้กันเองภายในบริษัท ไม่ได้อยากให้โลกใช้ เราสามารถตั้ง private git repository ขึ้นมาเองได้ด้วยเหมือนกันไม่มีปัญหา
Golang เวอร์ชันหลังๆจะมีระบบ package แถมเข้ามาด้วย(สมัยก่อนไม่มีนะ) เราสามารถเริ่มโมดูลของเราด้วยคำสั่ง go mod init {path to package}
(mod มาจากคำว่า module) โดย {path to package} เราตั้งอะไรก็ได้สมมติๆไปก่อนจนกว่าจะเอาโค้ดขึ้น repo จริงค่อยเปลี่ยนให้ตรงก็ยังได้
จากตัวอย่างผมจะตั้งโมดูลนึงขึ้นมา ด้วย path สมมติดังนี้
ถึงตรงนี้เราสามารถใช้โมดูลที่คนอื่นเขียนไว้มาใช้ในงานตัวเองได้ด้วยการสั่ง go get {path to package}
แล้วมันก็จะบันทึก dependency ลงในไฟล์ go.mod ภายในโปรเจ็กต์ (มันจะขึ้นมาเองเลยเหมือนของโหนด ไม่ต้องไปใส่เองล่ะ!!)
ตัวอย่างนี้ผม $ go get -u github.com/labstack/echo/...
มาซึ่งเป็นโมดูลที่ใช้ทำ http server
ถ้าโปรแกรมเรามีขนาดใหญ่ เราคงจะไม่เททุกสิ่งลงในไฟล์เดียวแน่ๆ node.js ให้เราแบ่งโค้ดออกเป็นไฟล์เล็กๆ แต่ละไฟล์มองว่าเป็นโมดูลย่อยและภายในแต่ละอันสามารถ export สิ่งที่ต้องการให้ไฟล์อื่นใช้ออกมาได้ทำให้เราแบ่งโค้ดออกเป็นสัดส่วน
Golang มีคอนเซ็ปคล้ายๆแต่ก็ไม่เหมือน ในแต่ละโมดูลที่สร้างขึ้นมาจะประกอบด้วย package(แพ็กเกจ) ย่อยๆลงมาได้ ซึ่งแพ็กเกจเหล่านั้นสร้างด้วยการทำ sub-folder และโค้ดโมดูลของเราจะต้องมีอย่างน้อย 1 แพ็กเกจที่ชื่อว่า main
ตัวอย่างรูปคือเราสร้างแพ็กเกจชื่อว่า main ขึ้นมา ทั้ง main.go และ printer.go อยู่ใน folder เดียวกันจึงมี package ชื่อเดียวกันคือ main
โค้ดภายในแพ็กเกจเดียวกันสามารถใช้งานข้ามไฟล์ได้โดยไม่ต้อง import ทำให้ไฟล์ main.go สามารถเรียกฟังก์ชันใน printer.go ได้เลย
หากว่าโค้ดของเรามีความซับซ้อนมากขึ้น การแบ่งแพ็กเกจออกมาจะทำให้โค้ดเรามีระเบียบขึ้น ในแพ็กเกจนึงของ go ไม่ควรทำอะไรได้หลายอย่าง ควรจะให้แพ็กเกจโฟกัสกับการทำงานเพียงอย่างเดียวเท่านั้น
เราจะมาลองแยกแพ็กเกจกันดู
จากรูปเป็นการสร้าง package ใหม่ภายใต้โมดูลที่เราเขียนขึ้นมาด้วยการสร้าง sub-folder แล้วไฟล์ในนั้นจะถือว่าเป็นส่วนนึงของแพ็กเกจ โดยแพ็กเกจจะมีชื่อตรงกับชื่อโฟลเดอร์
ถึงแม้ว่าเราเขียนโค้ดภายในโมดูลเดียวกัน แต่ถ้าโค้ดอยู่คนละแพ็กเกจเราจะไม่สามารถใช้โค้ดข้ามกันได้ จะต้องทำการ import เข้ามาก่อน
การ import แพ็กเกจเราเพียงแค่ไล่ folder ให้ถูกเท่านั้น จากตัวอย่าง package user ถูกสร้างขึ้นมาภายใน module github.com/muitsfriday/strutil เวลา import ก็จะได้เป็น github.com/muitsfriday/strutil/user
สิ่งที่ได้จากการ import มาคือ function/struct/ตัวแปร ที่ package นั้น export ออกมาให้ใช้ ด้วยการตั้งชื่อนำด้วยตัวอักษรตัวใหญ่ ถ้าหากของในแพ็กเกจถูกตั้งชื่อขึ้นต้นด้วยตัวอักษาตัวเล็ก ข้างนอกแพ็กเกจจะไม่สามารถอ้างถึงชื่อนั้นๆได้
เรื่อง package ของโกนี่ทำผมงงนานมาก เพราะชินกับการ require ไฟล์ของ node.js ไปแล้ว กว่าจะพอเก็ต(หวังว่านะ)ใช้เวลาสักพักเหมือนกัน
Golang ตัวภาษาเป็น strong type คอมไพล์เลอร์ต้องรู้ตัวเสมอว่าตัวแปรที่ถืออยู่นี่คือประเภทอะไร ตรงนี้ช่วยลดความผิดพลาดจากการ refactor ผิดๆหรือส่งค่าไม่ตรงสิ่งที่คาดหวังที่เรามักจะเจอใน node ไปได้เยอะมาก
แต่ถามผมถ้าจะบอกว่าเพราะอย่างงี้ golang เลยดีกว่า node ผมก็จะไม่เห็นด้วยเพราะฝั่ง node ก็มีตัวคอมไพล์แปลงโค้ดจาก typescript ได้ซึ่ง typescript เป็นอะไรที่ผมว่ามันดีมากๆ 5555
ข้อได้เปรียบของ golang อาจจะเป็นตรงที่มันอยู่ในตัวภาษาเลยส่วน node.js เราต้องไปเซ็ตอัป typescript ในโปรเจ็กต์ก่อน (หรือไม่ก็เปลี่ยนไปใช้ deno แทน 555)
ส่วนเรื่องที่ผมอยากได้จาก type ของ go เนี่ยคือ อยากได้ generic แต่ก็มองว่ามันทำให้ go หลุดคอนเซ็ปของความเรียบง่ายไปมากเหมือนกันถ้าจะมีจริงๆ แต่เหมือนว่าจะมาเร็วๆนี้แล้วล่ะ
การมาของ generic น่าจะทำให้ชีวิตของ golang dev ง่ายขึ้นมากๆ น่าจะมี utility module ออกมาให้เราใช้กันอย่างมากมาย
อันนี้ผมว่าดีกว่าฝั่ง node มากๆคือเรื่องของการ handle error ตัวภาษาบังคับออกแบบมาให้เรา ✌ชอบ✌ การจัดการ error ที่จะเกิดขึ้นในการรันคำสั่งใดๆถ้ามันเป้นไปได้
ด้วยการยอมให้ function สามารถ return ค่าออกมาได้มากกว่า 1 ค่าได้ และมีแนวทางปฏิบัติว่า ถ้า function สามารถเกิดข้อผิดพลาดในการทำงานได้ เราจะคืนค่า error ออกมาเป็นตัวท้ายสุด
ยกตัวอย่าง
function div(n, d) {
if (d === 0) throw new Error("cannot div with zero")
return n / d
}
function main() {
try {
const x = div(10, 0)
} catch (e) {
// ... handle
}
}
ใน node.js เราใช้การ throw เพื่อคาย error ออกมา
func div(n int, d int) (int, error) {
if d == 0 {
return 0, erorrs.New("cannot div with zero")
}
return n / d, nil
}
func main() {
r, err := div(10, 0)
if err != nil {
logger.Error(err.Error())
}
}
ใน golang จะเขียนให้คาย error แทนข้อดีของการทำแบบนี้จากการที่ผมเขียนมามีดังนี้
func main() {
r, err := div(10, 0) // << จุดนี้บังคับให้เราต้องเอาตัวแปรมารับ error เราจะมีสติเสมอว่า ฟังก์ชันนี้มัน error ได้นะ
}
div
ในตัวอย่าง ของ node.js จะอยู่ภายใน try block ซึ่งถ้าจะใช้ตัวแปรนี้ต่อเราจะต้องเขียนโค้ดนั้นในบล็อกนั้น ทำให้โค้ดมีแนวโน้มที่จะซ้อนเข้าไปเรื่อยๆ เช่นfunction main() {
try {
const x = div(10, 0)
try {
const y = div(12, 7)
} catch (e) {
// ... another handle
}
} catch (e) {
// ... handle
}
}
แก้ไขได้ด้วยการ…
function main() {
let x = 0
try {
x = div(10, 0)
} catch (e) {
// ... handle
}
let y = 0
try {
y = div(12, 7)
} catch (e) {
// ... handle
}
}
ผมไม่ค่อยชอบการทำแบบนี้เท่าไหร่ 555
ตรงนี้ต่างกันเล็กน้อยคือในโกไฟล์เทสจะตั้งชื่อเป็น {ชื่อไฟล์ที่จะเทส}_test.go ซึ่งจะทำให้ไฟล์อยู่ถัดลงมาจากไฟล์ต้นฉบับ ไม่ต้องเปลี่ยนโฟลเดอร์ไปมา ในขณะที่โหนดเรามักจะไปสร้าง folder test แยกต่างหาก
โกมีเครื่องมือการจัด format มาให้ ไม่ต้องเถียงกันแล้วว่าจะเว้นยังไง เขามีมาตรฐานแพ็กมาด้วยเลย
คำสั่ง test ก็มีมาให้คือ go test
ตัวภาษาโกพยายามล็อกให้เราเขียนโค้ดให้ดีด้วยการใส่เงื่อนไขตรวจสอบโค้ดเราหลายอย่างมากเช่น การเช็กตัวแปรที่สร้างมา แต่ไม่ได้ใช้งานอันนี้โกจะไม่ยอมเลย หรือจะเตือนเราถ้าเราไม่ได้ comment doc ในสิ่งที่เรา export ออกไปนอกแพ็กเกจ คือเขาพยายามให้โค้ดมี document ในตัวเองนั่นแหละ
จริงๆสิ่งเหล่านี้ก็ทำได้ใน node หมดเลยแต่ว่าอาจจะต้องเซ็ตติ้งเพิ่ม ให้มาเลยก็ดีกว่าเนอะ
โกคล้ายกับ C ตรงที่ให้เราเป็นคนเลือกเองว่าจะใช้หรือไม่ใช้ pointer ในบางจุด การส่งค่าผ่าน function ของโกทั้งหมดเป็น pass by copy ทั้งหมด ดังนั้นถ้าเราอยากให้ฟังก์ชันแก้ไขค่าตัวแปรที่ส่งเข้ามาแล้วส่งผลออกไปข้างนอกด้วย จะต้องส่ง pointer เข้าไปแทน ตรงนี้ถ้าไครงงๆ ไม่แม่นเรื่อง pointer จะเป็นอะไรที่งงมาก แต่เชื่อเถอะว่าทำๆไปเดี๋ยวก็ชินเองแหละ
ส่วน node นี่คือ magic เรากำหนดไม่ได้ว่าจะส่งเป็น ref หรือ value ค่าตัวแปรบางตัวจะออกมาในรูป pointer อัตโนมัติแบบที่เราแก้ไขอะไรไม่ได้และจุดนั้นมักจะก่อให้เกิดบัค ได้ง่ายมากๆถ้าเราไม่รู้หรือรู้แล้วแต่ลืมไป
func foo(u *User) error {
if u.ID == "" {
u.ID = "generated"
return nil
}
return errors.New("cannot create id when it already exists")
}
func main() {
u := User{}
// ส่งค่าเป็น pointer เข้าไปเพื่อให้ฟังก์ชันแก้ไข
_ := foo(&u)
}
ตัวอย่างของการส่งพอยเตอร์ของ struct User เข้าไปใน function
โดยส่วนตัวผมไม่ค่อยชอบให้ฟังก์ชันเอา input ที่เราใส่เข้าไปแก้ไขสักเท่าไหร่ เพราะผมว่ามันดูเมจิกมากๆ กรณีแบบนี้เหมาะสำหรับเราใส่ struct เปล่าๆเข้าไปเพื่อให้ฟังก์ชันคืนผลลัพธ์ออกมาทางนั้นแทนมากกว่า
Node มีกลไกที่ทำให้โค้ดรันแบบไม่บล็อกการทำงานได้ (เพราะจริงๆจาว่าสคริปรันเป็น single thread ทำได้ทีละอย่างในเวลานึง) ด้วย event loop ซึ่งโค้ดหลายๆอย่างที่ทำงานช้าจะถูกผลักลงไปตรงนั้นแทนหมดเพื่อไม่ให้ไปบล็อกคำสั่งอื่นๆที่จะทำงานต่อ แต่ในการทำ backend อาจจะไม่เห็นข้อเสียของการ blocking ที่ชัดเจนเท่า front-end สักเท่าไหร่
ใน Go คำสั่งทั้งหมดเป็น sync หมดเลยทำจากบนลงล่างแต่ถ้าเราอยากให้มันแยกออกไปรันต่างหากก็มีคอนเซ็ปที่เรียกว่า goroutine เหมือนเป็นการเอาฟังก์ชันไปรันที่เทรดอื่นเพื่อให้มันทำงานแบบ concurrent ได้ และมีการติดต่อสื่อสารกันผ่าน channel
func foo(c chan Int) error {
// สมมติไป fetch data จากข้างนอกมา
num := fetch()
// push data เข้าไปใน channel
c <- num
}
func main() {
// สร้าง channel
c := make(chan int, 1)
// ให้ foo ไปอยุ่อีก thread นึง ทำงานแบบ concurrent
go foo(c)
// อันนี้รันต่อเลย ไม่รอให้ foo รันเสร็จ
fmt.Println("test")
// รอ data ที่ถูก push จาก channel
data := <-c
}
คอนเซ็ปต์ของ concurrent จริงๆมีอะไรให้เล่นเยอะมากต้องลองไปอ่านเพิ่มเองผมเองยังใช้แค่ผิวๆอยุู่เลย ฮา https://tour.golang.org/concurrency/1
สุดท้ายนี้ช่วยอวยพรให้ผมอยู่รอดในโลก golang ด้วยนะครับ 5555