Go Programming Language: บทช่วยสอน Golang เบื้องต้น
เผยแพร่แล้ว: 2022-03-11ภาษาการเขียนโปรแกรม Go คืออะไร?
ภาษาการเขียนโปรแกรม Go ที่ค่อนข้างใหม่จะอยู่ตรงกลางของภูมิประเทศ ให้คุณลักษณะที่ดีมากมาย และจงใจละเว้นภาษาที่ไม่ดีจำนวนมาก มันคอมไพล์อย่างรวดเร็ว รันเร็ว-ish รวมถึงรันไทม์และการรวบรวมขยะ มีระบบประเภทสแตติกที่เรียบง่ายและอินเทอร์เฟซแบบไดนามิก และไลบรารีมาตรฐานที่ยอดเยี่ยม นี่คือเหตุผลที่นักพัฒนาจำนวนมากกระตือรือร้นที่จะเรียนรู้การเขียนโปรแกรม Go
OOP เป็นหนึ่งในคุณสมบัติที่ Go ตั้งใจละเว้น มันไม่มีคลาสย่อย ดังนั้นจึงไม่มีเพชรที่สืบทอดหรือ super call หรือวิธีเสมือนที่จะพาคุณไป อย่างไรก็ตาม ส่วนที่เป็นประโยชน์มากมายของ OOP นั้นมีให้ในรูปแบบอื่น
*Mixins* สามารถใช้ได้โดยการฝัง struct โดยไม่เปิดเผยตัวตน ทำให้สามารถเรียกเมธอดของพวกมันได้โดยตรงบนโครงสร้างที่มี (ดูการฝัง) วิธีการส่งเสริมในลักษณะนี้เรียกว่า *การส่งต่อ* และไม่เหมือนกับคลาสย่อย: วิธีการจะยังคงถูกเรียกใช้บนโครงสร้างภายในที่ฝังตัว
การฝังไม่ได้หมายความถึงความหลากหลาย แม้ว่า "A" อาจ มี "B" แต่นั่นไม่ได้หมายความว่ามัน เป็น "B" -- ฟังก์ชันที่ใช้ "B" จะไม่รับ "A" แทน สำหรับสิ่งนั้น เราจำเป็นต้องมี อินเทอร์เฟซ ซึ่งเราจะพบในภายหลังโดยสังเขป
ในขณะเดียวกัน Golang มีจุดแข็งในด้านคุณลักษณะที่อาจนำไปสู่ความสับสนและข้อบกพร่อง โดยละเว้นสำนวน OOP เช่น การสืบทอดและความหลากหลาย เพื่อสนับสนุนองค์ประกอบและอินเทอร์เฟซที่เรียบง่าย มันลดทอนการจัดการข้อยกเว้นเพื่อสนับสนุนข้อผิดพลาดที่ชัดเจนในค่าที่ส่งคืน มีวิธีที่ถูกต้องเพียงวิธีเดียวในการจัดวางโค้ด Go ซึ่งบังคับใช้โดยเครื่องมือ gofmt
และอื่นๆ.
ทำไมต้องเรียนโกลัง?
Go เป็นภาษาที่ยอดเยี่ยมสำหรับการเขียน โปรแกรมพร้อมกัน : โปรแกรมที่มีส่วนทำงานอิสระจำนวนมาก ตัวอย่างที่ชัดเจนคือเว็บเซิร์ฟเวอร์: ทุกคำขอทำงานแยกกัน แต่คำขอมักจะต้องแชร์ทรัพยากร เช่น เซสชัน แคช หรือคิวการแจ้งเตือน ซึ่งหมายความว่าโปรแกรมเมอร์ Go ที่มีทักษะจำเป็นต้องจัดการกับการเข้าถึงทรัพยากรเหล่านั้นพร้อมกัน
แม้ว่า Golang จะมีชุดคุณสมบัติระดับต่ำที่ยอดเยี่ยมสำหรับการจัดการภาวะพร้อมกัน แต่การใช้งานโดยตรงอาจกลายเป็นเรื่องที่ซับซ้อน ในหลายกรณี การนำสิ่งที่เป็นนามธรรมมาใช้ซ้ำจำนวนหนึ่งบนกลไกระดับต่ำเหล่านั้นทำให้ชีวิตง่ายขึ้นมาก
ในบทช่วยสอนการเขียนโปรแกรม Go วันนี้ เราจะมาดูสิ่งที่เป็นนามธรรมอย่างหนึ่ง: เสื้อคลุมที่สามารถเปลี่ยนโครงสร้างข้อมูลใดๆ ให้เป็น บริการด้านธุรกรรม เราจะใช้ประเภท Fund
เป็นตัวอย่าง – ร้านค้าง่ายๆ สำหรับเงินทุนที่เหลืออยู่ของสตาร์ทอัพ ซึ่งเราสามารถตรวจสอบยอดเงินคงเหลือและทำการถอนเงินได้
เพื่อสาธิตสิ่งนี้ในทางปฏิบัติ เราจะสร้างบริการด้วยขั้นตอนเล็ก ๆ ทำให้เกิดความยุ่งเหยิงระหว่างทางแล้วทำความสะอาดอีกครั้ง ขณะที่เราดำเนินการผ่านบทช่วยสอน Go เราจะพบกับฟีเจอร์ภาษา Go ที่ยอดเยี่ยมมากมาย รวมถึง:
- ประเภทโครงสร้างและวิธีการ
- การทดสอบหน่วยและการวัดประสิทธิภาพ
- Goroutines และช่อง
- อินเทอร์เฟซและการพิมพ์แบบไดนามิก
การสร้างกองทุนอย่างง่าย
มาเขียนโค้ดเพื่อติดตามเงินทุนสตาร์ทอัพของเรากัน กองทุนเริ่มต้นด้วยยอดคงเหลือที่กำหนด และสามารถถอนเงินได้เท่านั้น (เราจะหารายได้ในภายหลัง)
Go นั้นจงใจ ไม่ใช่ ภาษาเชิงวัตถุ: ไม่มีคลาส วัตถุ หรือการสืบทอด แต่เราจะประกาศ ประเภท โครงสร้างที่เรียกว่า Fund
โดยมีฟังก์ชันง่ายๆ เพื่อสร้างโครงสร้างกองทุนใหม่และวิธีสาธารณะสองวิธี
fund.go
package funding type Fund struct { // balance is unexported (private), because it's lowercase balance int } // A regular function returning a pointer to a fund func NewFund(initialBalance int) *Fund { // We can return a pointer to a new struct without worrying about // whether it's on the stack or heap: Go figures that out for us. return &Fund{ balance: initialBalance, } } // Methods start with a *receiver*, in this case a Fund pointer func (f *Fund) Balance() int { return f.balance } func (f *Fund) Withdraw(amount int) { f.balance -= amount }
การทดสอบด้วยเกณฑ์มาตรฐาน
ต่อไปเราต้องมีวิธีการทดสอบ Fund
. แทนที่จะเขียนโปรแกรมแยกต่างหาก เราจะใช้แพ็คเกจการทดสอบของ Go ซึ่งมีกรอบงานสำหรับทั้งการทดสอบหน่วยและการวัดประสิทธิภาพ ตรรกะง่ายๆ ใน Fund
ของเราไม่คุ้มที่จะเขียนแบบทดสอบหน่วยลงทุน แต่เนื่องจากเราจะพูดถึงเรื่องการเข้าถึงกองทุนพร้อมกันในภายภาคหน้าเป็นอย่างมาก การเขียนเกณฑ์ชี้วัดจึงสมเหตุสมผล
เกณฑ์มาตรฐานเป็นเหมือนการทดสอบหน่วย แต่รวมลูปที่รันโค้ดเดียวกันหลายครั้ง (ในกรณีของเรา fund.Withdraw(1)
) ซึ่งช่วยให้เฟรมเวิร์กสามารถจับเวลาได้ว่าการวนซ้ำแต่ละครั้งใช้เวลานานเท่าใด หาค่าเฉลี่ยความแตกต่างชั่วคราวจากการค้นหาดิสก์ แคชที่ขาดหายไป การจัดตารางกระบวนการ และปัจจัยอื่นๆ ที่คาดเดาไม่ได้
กรอบงานการทดสอบต้องการให้การวัดประสิทธิภาพแต่ละรายการทำงานเป็นเวลาอย่างน้อย 1 วินาที (โดยค่าเริ่มต้น) เพื่อให้แน่ใจว่าสิ่งนี้ จะเรียกการวัดประสิทธิภาพหลายครั้ง โดยส่งผ่านค่า "จำนวนการวนซ้ำ" ที่เพิ่มขึ้นในแต่ละครั้ง (ฟิลด์ bN
) จนกว่าการดำเนินการจะใช้เวลาอย่างน้อยหนึ่งวินาที
สำหรับตอนนี้ เกณฑ์มาตรฐานของเราจะฝากเงินแล้วถอนออกครั้งละหนึ่งดอลลาร์
fund_test.go
package funding import "testing" func BenchmarkFund(b *testing.B) { // Add as many dollars as we have iterations this run fund := NewFund(bN) // Burn through them one at a time until they are all gone for i := 0; i < bN; i++ { fund.Withdraw(1) } if fund.Balance() != 0 { b.Error("Balance wasn't zero:", fund.Balance()) } }
ตอนนี้เรามาเริ่มกันเลย:
$ go test -bench . funding testing: warning: no tests to run PASS BenchmarkWithdrawals 2000000000 1.69 ns/op ok funding 3.576s
ที่ผ่านไปด้วยดี เราดำเนินการซ้ำสองพันล้านครั้ง (!) และการตรวจสอบยอดคงเหลือในขั้นสุดท้ายนั้นถูกต้อง เราสามารถเพิกเฉยต่อคำเตือน "ไม่มีการทดสอบใดๆ ที่จะเรียกใช้" ซึ่งหมายถึงการทดสอบหน่วยที่เราไม่ได้เขียน (ในตัวอย่างการเขียนโปรแกรม Go ในภายหลังในบทช่วยสอนนี้ คำเตือนถูกตัดออกไป)
การเข้าถึงพร้อมกันใน Go
ตอนนี้ มาสร้างเกณฑ์เปรียบเทียบพร้อมกัน เพื่อจำลองผู้ใช้ต่าง ๆ ที่ทำการถอนเงินในเวลาเดียวกัน ในการทำเช่นนั้น เราจะวางไข่โกรูทีนสิบตัว และให้แต่ละตัวถอนเงินหนึ่งในสิบ
Goroutines เป็นส่วนประกอบพื้นฐานสำหรับการทำงานพร้อมกันในภาษา Go เป็นเธรดสีเขียว – เธรดน้ำหนักเบาที่จัดการโดยรันไทม์ Go ไม่ใช่โดยระบบปฏิบัติการ ซึ่งหมายความว่าคุณสามารถเรียกใช้งานเหล่านี้ได้หลายพัน (หรือนับล้าน) โดยไม่มีค่าโสหุ้ยที่สำคัญ Goroutines เกิดขึ้นพร้อมกับคีย์เวิร์ด go
และเริ่มต้นด้วยฟังก์ชัน (หรือการเรียกใช้เมธอด):
// Returns immediately, without waiting for `DoSomething()` to complete go DoSomething()
บ่อยครั้ง เราต้องการเรียกใช้ฟังก์ชันการทำงานครั้งเดียวสั้นๆ ที่มีโค้ดเพียงไม่กี่บรรทัด ในกรณีนี้ เราสามารถใช้การปิดแทนชื่อฟังก์ชันได้:
go func() { // ... do stuff ... }() // Must be a function *call*, so remember the ()
เมื่อ goroutines ของเราเกิดขึ้นแล้ว เราต้องการวิธีรอให้พวกมันทำงานให้เสร็จ เราสามารถสร้างมันขึ้นมาเองโดยใช้ ช่อง แต่เรายังไม่เคยเจอมันเลย ดังนั้นมันจะต้องข้ามไปข้างหน้า
สำหรับตอนนี้ เราสามารถใช้ประเภท WaitGroup
ในไลบรารีมาตรฐานของ Go ซึ่งมีไว้เพื่อจุดประสงค์นี้เท่านั้น เราจะสร้างหนึ่งรายการ (เรียกว่า “ wg
”) และเรียก wg.Add(1)
ก่อนที่จะวางไข่ผู้ปฏิบัติงานแต่ละคน เพื่อติดตามว่ามีกี่คน จากนั้นคนงานจะรายงานกลับโดยใช้ wg.Done()
ในขณะเดียวกันใน goroutine หลัก เราสามารถพูดว่า wg.Wait()
เพื่อบล็อกจนกว่าผู้ปฏิบัติงานทุกคนจะเสร็จสิ้น
ภายใน goroutine ของผู้ปฏิบัติงานในตัวอย่างต่อไป เราจะใช้ defer
เพื่อเรียก wg.Done()
defer
เรียกใช้ฟังก์ชัน (หรือเมธอด) และเรียกใช้ทันทีก่อนที่ฟังก์ชันปัจจุบันจะส่งคืน หลังจากที่ทุกอย่างเสร็จสิ้น สิ่งนี้มีประโยชน์สำหรับการล้างข้อมูล:
func() { resource.Lock() defer resource.Unlock() // Do stuff with resource }()
วิธีนี้ทำให้เราสามารถจับคู่ Unlock
กับ Lock
ได้อย่างง่ายดายเพื่อให้อ่านง่าย ที่สำคัญกว่านั้น ฟังก์ชันที่เลื่อนออกไปจะทำงาน แม้ว่าจะมีความตื่นตระหนก ในฟังก์ชันหลัก (สิ่งที่เราอาจจัดการผ่านการลองครั้งสุดท้ายในภาษาอื่น)
สุดท้าย ฟังก์ชันที่เลื่อนออกไปจะดำเนินการในลำดับ ย้อนกลับ ตามที่พวกเขาถูกเรียก ซึ่งหมายความว่าเราสามารถทำความสะอาดแบบซ้อนได้อย่างสวยงาม (คล้ายกับสำนวน C ของ goto
ที่ซ้อนกันและ label
แต่เรียบร้อยกว่ามาก):
func() { db.Connect() defer db.Disconnect() // If Begin panics, only db.Disconnect() will execute transaction.Begin() defer transaction.Close() // From here on, transaction.Close() will run first, // and then db.Disconnect() // ... }()
ตกลง จากทั้งหมดที่กล่าวมา นี่คือเวอร์ชันใหม่:
fund_test.go
package funding import ( "sync" "testing" ) const WORKERS = 10 func BenchmarkWithdrawals(b *testing.B) { // Skip N = 1 if bN < WORKERS { return } // Add as many dollars as we have iterations this run fund := NewFund(bN) // Casually assume bN divides cleanly dollarsPerFounder := bN / WORKERS // WaitGroup structs don't need to be initialized // (their "zero value" is ready to use). // So, we just declare one and then use it. var wg sync.WaitGroup for i := 0; i < WORKERS; i++ { // Let the waitgroup know we're adding a goroutine wg.Add(1) // Spawn off a founder worker, as a closure go func() { // Mark this worker done when the function finishes defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { fund.Withdraw(1) } }() // Remember to call the closure! } // Wait for all the workers to finish wg.Wait() if fund.Balance() != 0 { b.Error("Balance wasn't zero:", fund.Balance()) } }
เราสามารถทำนายว่าจะเกิดอะไรขึ้นที่นี่ พนักงานทั้งหมดจะดำเนินการ Withdraw
ทับกัน ข้างในนั้น f.balance -= amount
จะอ่านยอดคงเหลือ ลบหนึ่งรายการ แล้วเขียนกลับ แต่บางครั้งผู้ปฏิบัติงานตั้งแต่สองคนขึ้นไปจะอ่านยอดดุลเดียวกัน และทำการลบแบบเดียวกัน และเราจะลงเอยด้วยยอดรวมที่ไม่ถูกต้อง ใช่ไหม?
$ go test -bench . funding BenchmarkWithdrawals 2000000000 2.01 ns/op ok funding 4.220s
ไม่ มันยังคงผ่านไป เกิดอะไรขึ้นที่นี่?
โปรดจำไว้ว่า goroutines เป็น เธรดสีเขียว – จัดการโดยรันไทม์ Go ไม่ใช่โดย OS รันไทม์จะกำหนดเวลา goroutines ในทุกเธรดของระบบปฏิบัติการที่มีอยู่ ในขณะที่เขียนบทช่วยสอนภาษา Go นี้ Go ไม่ได้พยายามเดาว่าควรใช้ OS thread กี่เธรด และถ้าเราต้องการมากกว่าหนึ่งเธรด เราต้องพูดอย่างนั้น สุดท้าย รันไทม์ปัจจุบันจะไม่แทนที่ goroutines - goroutine จะยังคงทำงานต่อไปจนกว่าจะทำสิ่งที่แนะนำว่าพร้อมสำหรับการหยุดพัก (เช่น การโต้ตอบกับช่อง)
ทั้งหมดนี้หมายความว่าแม้ว่าการวัดประสิทธิภาพของเราจะเกิดขึ้นพร้อมกัน แต่ก็ไม่ ขนานกัน พนักงานของเราจะทำงานทีละคนเท่านั้น และจะทำงานจนกว่าจะเสร็จ เราสามารถเปลี่ยนสิ่งนี้ได้โดยบอกให้ Go ใช้เธรดมากขึ้น ผ่านตัวแปรสภาพแวดล้อม GOMAXPROCS
$ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 --- FAIL: BenchmarkWithdrawals-4 account_test.go:39: Balance wasn't zero: 4238 ok funding 0.007s
นั่นดีกว่า. เห็นได้ชัดว่าตอนนี้เราสูญเสียการถอนบางส่วนอย่างที่เราคาดไว้
ทำให้เป็นเซิร์ฟเวอร์
ณ จุดนี้เรามีตัวเลือกต่างๆ เราสามารถเพิ่มการล็อค mutex หรือ read-write ที่ชัดเจนรอบๆ กองทุนได้ เราสามารถใช้การเปรียบเทียบและสลับกับหมายเลขเวอร์ชัน เราสามารถทุ่มสุดตัวและใช้แผน CRDT (บางทีอาจแทนที่ฟิลด์ balance
ด้วยรายการธุรกรรมสำหรับลูกค้าแต่ละราย และคำนวณยอดจากสิ่งเหล่านั้น)
แต่เราจะไม่ทำสิ่งเหล่านั้นในตอนนี้ เพราะมันยุ่งหรือน่ากลัว หรือทั้งสองอย่าง แต่เราจะตัดสินใจว่ากองทุนควรเป็น เซิร์ฟเวอร์ เซิร์ฟเวอร์คืออะไร? มันเป็นสิ่งที่คุณคุยด้วย ใน Go สิ่งต่าง ๆ พูดคุยผ่านช่องทางต่างๆ
แชนเนลเป็นกลไกการสื่อสารพื้นฐานระหว่าง goroutines ค่าจะถูกส่งไปยังแชนเนล (พร้อม channel <- value
) และสามารถรับได้ในอีกด้านหนึ่ง (ด้วย value = <- channel
) ช่องสัญญาณมี "โกรูทีนที่ปลอดภัย" ซึ่งหมายความว่าสามารถส่งและรับจากโกรูทีนจำนวนเท่าใดก็ได้ในเวลาเดียวกัน
ช่องทางการสื่อสารบัฟเฟอร์สามารถเพิ่มประสิทธิภาพการทำงานได้ในบางสถานการณ์ แต่ควรใช้ด้วยความระมัดระวังอย่างยิ่ง (และการเปรียบเทียบ!)
อย่างไรก็ตาม มีการใช้สำหรับช่องสัญญาณบัฟเฟอร์ซึ่งไม่เกี่ยวกับการสื่อสารโดยตรง
ตัวอย่างเช่น สำนวนการควบคุมปริมาณทั่วไปจะสร้างแชนเนลที่มี (เช่น) ขนาดบัฟเฟอร์ "10" แล้วส่งโทเค็นสิบรายการเข้าไปทันที จากนั้นจะมีการสร้าง goroutine ผู้ปฏิบัติงานจำนวนเท่าใดก็ได้ และแต่ละตัวจะได้รับโทเค็นจากช่องสัญญาณก่อนเริ่มงาน และส่งกลับในภายหลัง ดังนั้นไม่ว่าจะมีคนงานกี่คน มีเพียงสิบคนเท่านั้นที่จะทำงานพร้อมๆ กัน
โดยค่าเริ่มต้น ช่อง Go จะไม่ถูก บัฟเฟอร์ ซึ่งหมายความว่าการส่งค่าไปยังช่องสัญญาณจะถูกบล็อกจนกว่า goroutine อื่นพร้อมที่จะรับทันที Go ยังรองรับขนาดบัฟเฟอร์คงที่สำหรับช่องสัญญาณ (โดยใช้ make(chan someType, bufferSize)
) อย่างไรก็ตาม สำหรับการใช้งานปกติ นี่เป็น ความคิดที่ไม่ดี
ลองนึกภาพเว็บเซิร์ฟเวอร์สำหรับกองทุนของเรา ซึ่งแต่ละคำขอจะทำการถอนเงิน เมื่องานยุ่งมาก FundServer
จะไม่สามารถติดตามได้ และคำขอที่พยายามส่งไปยังช่องคำสั่งจะเริ่มบล็อกและรอ ณ จุดนั้น เราสามารถบังคับใช้จำนวนคำขอสูงสุดในเซิร์ฟเวอร์ และส่งคืนรหัสข้อผิดพลาดที่สมเหตุสมผล (เช่น 503 Service Unavailable
) ให้กับลูกค้าที่เกินขีดจำกัดนั้น นี่เป็นลักษณะการทำงานที่ดีที่สุดเมื่อเซิร์ฟเวอร์โอเวอร์โหลด

การเพิ่มบัฟเฟอร์ให้กับแชนเนลของเราจะทำให้พฤติกรรมนี้กำหนดได้น้อยลง เราอาจจบลงด้วยคิวยาวของคำสั่งที่ยังไม่ได้ประมวลผลโดยอิงตามข้อมูลที่ลูกค้าเห็นก่อนหน้านี้มาก (และบางทีสำหรับคำขอที่หมดเวลาต้นน้ำแล้ว) เช่นเดียวกับในสถานการณ์อื่นๆ เช่น การใช้แรงดันย้อนกลับบน TCP เมื่อผู้รับไม่สามารถตามผู้ส่งได้
ไม่ว่าในกรณีใด สำหรับตัวอย่าง Go เราจะยึดตามการทำงานเริ่มต้นที่ไม่มีบัฟเฟอร์
เราจะใช้ช่องทางในการส่งคำสั่งไปยัง FundServer
ของเรา ผู้ปฏิบัติงานการวัดประสิทธิภาพทุกคนจะส่งคำสั่งไปยังช่องสัญญาณ แต่เฉพาะเซิร์ฟเวอร์เท่านั้นที่จะได้รับคำสั่งเหล่านั้น
เราสามารถเปลี่ยนประเภทกองทุนของเราเป็นการปรับใช้เซิร์ฟเวอร์ได้โดยตรง แต่นั่นอาจทำให้ยุ่งเหยิง – เราจะผสมผสานการจัดการภาวะพร้อมกันและตรรกะทางธุรกิจ เราจะปล่อยให้ประเภท Fund เหมือนเดิม และทำให้ FundServer
เป็น wrapper แยกจากกัน
เช่นเดียวกับเซิร์ฟเวอร์อื่นๆ Wrapper จะมีลูปหลักที่รอคำสั่งและตอบสนองต่อแต่ละคำสั่งตามลำดับ มีรายละเอียดเพิ่มเติมที่เราจำเป็นต้องกล่าวถึงที่นี่: ประเภทของคำสั่ง
เราสามารถทำให้ช่องคำสั่งของเราใช้ *ตัวชี้* ไปยังคำสั่ง (`chan *TransactionCommand`) ทำไมเราไม่ได้?
การส่งผ่านตัวชี้ระหว่าง goroutines นั้นมีความเสี่ยง เนื่องจาก goroutine ตัวใดตัวหนึ่งอาจแก้ไขได้ นอกจากนี้ยังมักจะมีประสิทธิภาพน้อยกว่า เนื่องจาก goroutine อื่นอาจทำงานบนแกน CPU อื่น (หมายถึงการทำให้แคชใช้งานไม่ได้มากขึ้น)
เมื่อใดก็ตามที่เป็นไปได้ ให้ส่งค่าธรรมดาไปรอบๆ
ในส่วนถัดไปด้านล่าง เราจะส่งคำสั่งต่างๆ หลายคำสั่ง โดยแต่ละคำสั่งจะมีประเภทโครงสร้างเป็นของตัวเอง เราต้องการให้ช่องคำสั่งของเซิร์ฟเวอร์ยอมรับช่องใดช่องหนึ่ง ในภาษา OOP เราอาจทำได้โดยใช้ polymorphism: ให้ channel ใช้ superclass ซึ่งแต่ละประเภทคำสั่งเป็น subclasses ใน Go เราใช้ อินเทอร์เฟซ แทน
อินเทอร์เฟซคือชุดของลายเซ็นเมธอด ประเภทใดก็ตามที่ใช้วิธีการเหล่านั้นทั้งหมดสามารถถือเป็นอินเทอร์เฟซนั้นได้ (โดยไม่ต้องประกาศให้ทำเช่นนั้น) สำหรับการรันครั้งแรก โครงสร้างคำสั่งของเราจะไม่เปิดเผยวิธีการใด ๆ ดังนั้นเราจะใช้อินเทอร์เฟซว่าง interface{}
เนื่องจากไม่มีข้อกำหนด ค่า ใดๆ (รวมถึงค่าดั้งเดิม เช่น จำนวนเต็ม) จะตอบสนองอินเทอร์เฟซที่ว่างเปล่า นี่ไม่เหมาะ – เราเพียงต้องการยอมรับโครงสร้างคำสั่ง – แต่เราจะกลับมาในภายหลัง
สำหรับตอนนี้ มาเริ่มกันที่โครงนั่งร้านสำหรับเซิร์ฟเวอร์ Go ของเรา:
server.go
package funding type FundServer struct { Commands chan interface{} fund Fund } func NewFundServer(initialBalance int) *FundServer { server := &FundServer{ // make() creates builtins like channels, maps, and slices Commands: make(chan interface{}), fund: NewFund(initialBalance), } // Spawn off the server's main loop immediately go server.loop() return server } func (s *FundServer) loop() { // The built-in "range" clause can iterate over channels, // amongst other things for command := range s.Commands { // Handle the command } }
ตอนนี้ มาเพิ่มประเภทโครงสร้าง Golang สองสามประเภทสำหรับคำสั่ง:
type WithdrawCommand struct { Amount int } type BalanceCommand struct { Response chan int }
WithdrawCommand
มีเพียงจำนวนเงินที่จะถอน ไม่มีการตอบสนอง BalanceCommand
มีการตอบกลับ ดังนั้นจึงมีช่องทางสำหรับส่งต่อไป สิ่งนี้ทำให้มั่นใจได้ว่าคำตอบจะไปถูกที่เสมอ แม้ว่าในเวลาต่อมากองทุนของเราจะตัดสินใจตอบสนองที่ไม่เป็นระเบียบก็ตาม
ตอนนี้เราสามารถเขียนลูปหลักของเซิร์ฟเวอร์ได้:
func (s *FundServer) loop() { for command := range s.Commands { // command is just an interface{}, but we can check its real type switch command.(type) { case WithdrawCommand: // And then use a "type assertion" to convert it withdrawal := command.(WithdrawCommand) s.fund.Withdraw(withdrawal.Amount) case BalanceCommand: getBalance := command.(BalanceCommand) balance := s.fund.Balance() getBalance.Response <- balance default: panic(fmt.Sprintf("Unrecognized command: %v", command)) } } }
อืม. นั่นเป็นสิ่งที่น่าเกลียด เรากำลังเปิดประเภทคำสั่ง โดยใช้การยืนยันประเภท และอาจหยุดทำงาน ก้าวไปข้างหน้าต่อไปและอัปเดตเกณฑ์มาตรฐานเพื่อใช้เซิร์ฟเวอร์
func BenchmarkWithdrawals(b *testing.B) { // ... server := NewFundServer(bN) // ... // Spawn off the workers for i := 0; i < WORKERS; i++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { server.Commands <- WithdrawCommand{ Amount: 1 } } }() } // ... balanceResponseChan := make(chan int) server.Commands <- BalanceCommand{ Response: balanceResponseChan } balance := <- balanceResponseChan if balance != 0 { b.Error("Balance wasn't zero:", balance) } }
นั่นก็น่าเกลียดเหมือนกัน โดยเฉพาะอย่างยิ่งเมื่อเราตรวจสอบยอดเงินคงเหลือ ช่างเถอะ. มาลองดูกัน:
$ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 5000000 465 ns/op ok funding 2.822s
ดีกว่ามาก เราไม่เสียการถอนอีกต่อไป แต่โค้ดเริ่มอ่านยาก และมีปัญหาร้ายแรงกว่านั้น หากเราเคยออก BalanceCommand
แล้วลืมอ่านคำตอบ เซิร์ฟเวอร์กองทุนของเราจะบล็อกการพยายามส่งไปตลอดกาล มาทำความสะอาดกันหน่อย
ทำให้เป็นบริการ
เซิร์ฟเวอร์คือสิ่งที่คุณคุยด้วย บริการคืออะไร? บริการคือสิ่งที่คุณพูดคุย กับ API แทนที่จะให้รหัสลูกค้าทำงานกับช่องคำสั่งโดยตรง เราจะทำให้ช่องนั้นไม่ถูกส่งออก (ส่วนตัว) และรวมคำสั่งที่มีอยู่ไว้ในฟังก์ชัน
type FundServer struct { commands chan interface{} // Lowercase name, unexported // ... } func (s *FundServer) Balance() int { responseChan := make(chan int) s.commands <- BalanceCommand{ Response: responseChan } return <- responseChan } func (s *FundServer) Withdraw(amount int) { s.commands <- WithdrawCommand{ Amount: amount } }
ตอนนี้เกณฑ์มาตรฐานของเราสามารถพูดได้เพียงว่า server.Withdraw(1)
และ balance := server.Balance()
และมีโอกาสน้อยที่จะส่งคำสั่งที่ไม่ถูกต้องหรือลืมอ่านคำตอบโดยไม่ได้ตั้งใจ
ยังมีต้นแบบเพิ่มเติมอีกมากสำหรับคำสั่ง แต่เราจะกลับมาที่ในภายหลัง
ธุรกรรม
ในที่สุด เงินก็หมดลงทุกที ตกลงกันว่าเราจะหยุดถอนเงินเมื่อกองทุนของเราเหลือสิบดอลลาร์สุดท้าย และใช้เงินนั้นไปกับพิซซ่าส่วนกลางเพื่อเฉลิมฉลองหรือแสดงความเห็นใจ เกณฑ์มาตรฐานของเราจะสะท้อนสิ่งนี้:
// Spawn off the workers for i := 0; i < WORKERS; i++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { // Stop when we're down to pizza money if server.Balance() <= 10 { break } server.Withdraw(1) } }() } // ... balance := server.Balance() if balance != 10 { b.Error("Balance wasn't ten dollars:", balance) }
คราวนี้เราสามารถคาดเดาผลลัพธ์ได้จริงๆ
$ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 --- FAIL: BenchmarkWithdrawals-4 fund_test.go:43: Balance wasn't ten dollars: 6 ok funding 0.009s
เรากลับมาที่จุดเริ่มต้นแล้ว พนักงานหลายคนสามารถอ่านยอดคงเหลือในครั้งเดียว แล้วอัปเดตทั้งหมด เพื่อจัดการกับสิ่งนี้ เราสามารถเพิ่มตรรกะบางอย่างในกองทุนได้ เช่น ทรัพย์สิน minimumBalance
หรือเพิ่มคำสั่งอื่นที่เรียกว่า WithdrawIfOverXDollars
นี่เป็นความคิดที่แย่มาก ข้อตกลงของเราเป็นข้อตกลงระหว่างเราเอง ไม่ใช่ทรัพย์สินของกองทุน เราควรเก็บไว้ในตรรกะของแอปพลิเคชัน
สิ่งที่เราต้องการจริงๆ คือ การทำธุรกรรม ในแง่เดียวกับธุรกรรมฐานข้อมูล เนื่องจากบริการของเราดำเนินการคำสั่งครั้งละหนึ่งคำสั่งเท่านั้น การดำเนินการนี้จึงง่ายมาก เราจะเพิ่มคำสั่ง Transact
ซึ่งมีการเรียกกลับ (การปิด) เซิร์ฟเวอร์จะดำเนินการเรียกกลับนั้นภายใน goroutine ของตัวเอง โดยส่งผ่านไปยัง raw Fund
จากนั้นการโทรกลับสามารถทำทุกอย่างที่ต้องการกับ Fund
ได้อย่างปลอดภัย
ในตัวอย่างต่อไปนี้ เรากำลังทำผิดสองสิ่งเล็กน้อย
ขั้นแรก เราใช้ช่อง "เสร็จสิ้น" เป็นสัญญาณเพื่อให้รหัสการโทรทราบเมื่อธุรกรรมเสร็จสิ้น ไม่เป็นไร แต่ทำไมช่องถึงเป็น `bool' เราจะส่ง "จริง" เข้าไปเพื่อหมายถึง "เสร็จสิ้น" เท่านั้น (การส่ง "เท็จ" หมายความว่าอย่างไร) สิ่งที่เราต้องการจริงๆ คือ ค่าสถานะเดียว (ค่าที่ไม่มีค่า?) ใน Go เราสามารถทำได้โดยใช้ประเภทโครงสร้างว่าง: `struct{}` นอกจากนี้ยังมีข้อดีของการใช้หน่วยความจำน้อยลง ในตัวอย่าง เราจะใช้ `bool' เพื่อไม่ให้ดูน่ากลัวเกินไป
ประการที่สอง โทรกลับธุรกรรมของเราไม่ส่งคืนอะไรเลย อย่างที่เราจะได้เห็นกันในอีกสักครู่ เราสามารถดึงค่าจากการเรียกกลับเข้าสู่การเรียกโค้ดโดยใช้เทคนิคขอบเขต อย่างไรก็ตาม ธุรกรรมในระบบจริงอาจล้มเหลวในบางครั้ง ดังนั้นแบบแผน Go คือให้ธุรกรรมส่งคืน "ข้อผิดพลาด" (แล้วตรวจสอบว่าเป็น "ศูนย์" ในรหัสการโทรหรือไม่)
เราไม่ได้ทำอย่างนั้นในตอนนี้ เนื่องจากเราไม่มีข้อผิดพลาดใดๆ ที่จะสร้าง
// Typedef the callback for readability type Transactor func(fund *Fund) // Add a new command type with a callback and a semaphore channel type TransactionCommand struct { Transactor Transactor Done chan bool } // ... // Wrap it up neatly in an API method, like the other commands func (s *FundServer) Transact(transactor Transactor) { command := TransactionCommand{ Transactor: transactor, Done: make(chan bool), } s.commands <- command <- command.Done } // ... func (s *FundServer) loop() { for command := range s.commands { switch command.(type) { // ... case TransactionCommand: transaction := command.(TransactionCommand) transaction.Transactor(s.fund) transaction.Done <- true // ... } } }
การเรียกกลับธุรกรรมของเราไม่ได้ส่งคืนสิ่งใดโดยตรง แต่ภาษา Go ทำให้ง่ายต่อการรับค่าจากการปิดโดยตรง ดังนั้นเราจะทำในเกณฑ์มาตรฐานเพื่อตั้งค่าสถานะ pizzaTime
เมื่อเงินเหลือน้อย:
pizzaTime := false for i := 0; i < dollarsPerFounder; i++ { server.Transact(func(fund *Fund) { if fund.Balance() <= 10 { // Set it in the outside scope pizzaTime = true return } fund.Withdraw(1) }) if pizzaTime { break } }
และตรวจสอบว่าใช้งานได้:
$ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 5000000 775 ns/op ok funding 4.637s
ไม่มีอะไรนอกจากการทำธุรกรรม
คุณอาจเห็นโอกาสในการทำความสะอาดสิ่งต่างๆ มากขึ้นแล้วในตอนนี้ เนื่องจากเรามีคำสั่ง Transact
ทั่วไป เราจึงไม่ต้องการ WithdrawCommand
หรือ BalanceCommand
อีกต่อไป เราจะเขียนใหม่ในแง่ของการทำธุรกรรม:
func (s *FundServer) Balance() int { var balance int s.Transact(func(f *Fund) { balance = f.Balance() }) return balance } func (s *FundServer) Withdraw(amount int) { s.Transact(func (f *Fund) { f.Withdraw(amount) }) }
ตอนนี้คำสั่งเดียวที่เซิร์ฟเวอร์ใช้คือ TransactionCommand
ดังนั้นเราสามารถลบ interface{}
ที่ยุ่งเหยิงในการใช้งาน และให้มันยอมรับเฉพาะคำสั่งธุรกรรม:
type FundServer struct { commands chan TransactionCommand fund *Fund } func (s *FundServer) loop() { for transaction := range s.commands { // Now we don't need any type-switch mess transaction.Transactor(s.fund) transaction.Done <- true } }
ดีขึ้นมาก
มีขั้นตอนสุดท้ายที่เราสามารถทำได้ที่นี่ นอกเหนือจากฟังก์ชันอำนวยความสะดวกสำหรับ Balance
และการ Withdraw
แล้ว การใช้งานบริการจะไม่ผูกติดกับ Fund
อีกต่อไป แทนที่จะจัดการ Fund
มันสามารถจัดการ interface{}
และใช้เพื่อห่อ อะไรก็ได้ อย่างไรก็ตาม การเรียกกลับธุรกรรมแต่ละครั้งจะต้องแปลง interface{}
กลับไปเป็นมูลค่าจริง:
type Transactor func(interface{}) server.Transact(func(managedValue interface{}) { fund := managedValue.(*Fund) // Do stuff with fund ... })
สิ่งนี้น่าเกลียดและเกิดข้อผิดพลาดได้ง่าย สิ่งที่เราต้องการจริงๆ คือ compile-time generics ดังนั้นเราจึงสามารถ "สร้างเทมเพลต" เซิร์ฟเวอร์สำหรับประเภทใดประเภทหนึ่งได้ (เช่น *Fund
)
น่าเสียดายที่ Go ยังไม่รองรับยาชื่อสามัญ แต่อย่างใด คาดว่าจะมาถึงในที่สุด เมื่อมีคนเข้าใจรูปแบบและความหมายที่สมเหตุสมผล ในระหว่างนี้ การออกแบบส่วนต่อประสานที่ระมัดระวังมักจะขจัดความจำเป็นในการใช้ชื่อสามัญ และเมื่อไม่จำเป็น เราก็สามารถยืนยันประเภทได้ (ซึ่งจะมีการตรวจสอบขณะใช้งานจริง)
เราทำเสร็จแล้ว?
ใช่.
เอาล่ะ ไม่
ตัวอย่างเช่น:
ความตื่นตระหนกในการทำธุรกรรมจะฆ่าบริการทั้งหมด
ไม่มีการหมดเวลา ธุรกรรมที่ไม่มีวันส่งคืนจะปิดกั้นบริการตลอดไป
หากกองทุนของเราเพิ่มฟิลด์ใหม่บางส่วนและธุรกรรมขัดข้องในช่วงครึ่งหลังของการอัปเดต เราจะมีสถานะที่ไม่สอดคล้องกัน
ธุรกรรมสามารถรั่วไหลอ็อบเจ็กต์
Fund
ที่มีการจัดการ ซึ่งไม่ดีไม่มีวิธีที่เหมาะสมในการทำธุรกรรมระหว่างเงินหลาย ๆ กองทุน (เช่น การถอนออกจากกองทุนหนึ่งและการฝากเงินในกองทุนอื่น) เราไม่สามารถทำธุรกรรมของเราซ้อนกันได้เพราะมันจะทำให้เกิดการชะงักงัน
การรันธุรกรรมแบบอะซิงโครนัสตอนนี้จำเป็นต้องมี goroutine ใหม่และยุ่งมาก ในทำนองเดียวกัน เราอาจต้องการอ่านสถานะ
Fund
ล่าสุดจากที่อื่นในขณะที่ธุรกรรมระยะยาวกำลังดำเนินการอยู่
ในบทช่วยสอนภาษาการเขียนโปรแกรม Go ครั้งต่อไป เราจะมาดูวิธีแก้ไขปัญหาเหล่านี้