Bahasa Pemrograman Go: Tutorial Pengenalan Golang

Diterbitkan: 2022-03-11

Apa itu Bahasa Pemrograman Go?

Bahasa pemrograman Go yang relatif baru duduk rapi di tengah lanskap, menyediakan banyak fitur bagus dan sengaja menghilangkan banyak fitur buruk. Ini mengkompilasi cepat, berjalan cepat, termasuk runtime dan pengumpulan sampah, memiliki sistem tipe statis sederhana dan antarmuka dinamis, dan perpustakaan standar yang sangat baik. Inilah sebabnya mengapa begitu banyak pengembang yang tertarik untuk mempelajari pemrograman Go.

Tutorial golang: ilustrasi logo

Pergi dan OOP

OOP adalah salah satu fitur yang sengaja dihilangkan oleh Go. Itu tidak memiliki subkelas, jadi tidak ada berlian warisan atau panggilan super atau metode virtual untuk membuat Anda tersandung. Namun, banyak bagian OOP yang berguna tersedia dengan cara lain.

*Mixin* tersedia dengan menyematkan struct secara anonim, memungkinkan metodenya dipanggil langsung pada struct yang memuatnya (lihat embedding). Mempromosikan metode dengan cara ini disebut *penerusan*, dan ini tidak sama dengan subkelas: metode akan tetap dipanggil di struct dalam yang disematkan.

Penyematan juga tidak menyiratkan polimorfisme. Sementara `A` mungkin memiliki `B`, itu tidak berarti itu adalah `B` -- fungsi yang mengambil `B` tidak akan mengambil `A` sebagai gantinya. Untuk itu, kita membutuhkan interface , yang akan kita jumpai sebentar lagi.

Sementara itu, Golang mengambil posisi yang kuat pada fitur yang dapat menyebabkan kebingungan dan bug. Ini menghilangkan idiom OOP seperti pewarisan dan polimorfisme, demi komposisi dan antarmuka yang sederhana. Ini meremehkan penanganan pengecualian demi kesalahan eksplisit dalam nilai kembalian. Ada satu cara yang benar untuk meletakkan kode Go, yang diterapkan oleh alat gofmt . Dan seterusnya.

Mengapa Belajar Golang?

Go juga merupakan bahasa yang bagus untuk menulis program bersamaan : program dengan banyak bagian yang berjalan secara independen. Contoh yang jelas adalah server web: Setiap permintaan berjalan secara terpisah, tetapi permintaan sering kali perlu berbagi sumber daya seperti sesi, cache, atau antrian notifikasi. Ini berarti programmer Go yang terampil perlu menangani akses bersamaan ke sumber daya tersebut.

Meskipun Golang memiliki serangkaian fitur tingkat rendah yang sangat baik untuk menangani konkurensi, menggunakannya secara langsung dapat menjadi rumit. Dalam banyak kasus, segelintir abstraksi yang dapat digunakan kembali atas mekanisme tingkat rendah itu membuat hidup lebih mudah.

Dalam tutorial pemrograman Go hari ini, kita akan melihat salah satu abstraksi tersebut: Pembungkus yang dapat mengubah struktur data apa pun menjadi layanan transaksional . Kami akan menggunakan jenis Fund sebagai contoh – penyimpanan sederhana untuk sisa dana startup kami, tempat kami dapat memeriksa saldo dan melakukan penarikan.

Untuk mendemonstrasikan ini dalam praktik, kami akan membangun layanan dalam langkah-langkah kecil, membuat kekacauan di sepanjang jalan dan kemudian membersihkannya lagi. Saat kami melanjutkan tutorial Go kami, kami akan menemukan banyak fitur bahasa Go yang keren, termasuk:

  • Jenis dan metode struktur
  • Tes unit dan tolok ukur
  • Goroutine dan saluran
  • Antarmuka dan pengetikan dinamis

Membangun Dana Sederhana

Mari kita menulis beberapa kode untuk melacak pendanaan startup kita. Dana dimulai dengan saldo tertentu, dan uang hanya dapat ditarik (kita akan mengetahui pendapatannya nanti).

Grafik ini menggambarkan contoh goroutine sederhana menggunakan bahasa pemrograman Go.

Go sengaja bukan bahasa berorientasi objek: Tidak ada kelas, objek, atau pewarisan. Sebagai gantinya, kami akan mendeklarasikan tipe struct yang disebut Fund , dengan fungsi sederhana untuk membuat struct dana baru, dan dua metode publik.

dana.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 }

Menguji Dengan Tolok Ukur

Selanjutnya kita perlu cara untuk menguji Fund . Daripada menulis program terpisah, kami akan menggunakan paket pengujian Go, yang menyediakan kerangka kerja untuk pengujian unit dan benchmark. Logika sederhana di Fund kami tidak benar-benar layak untuk menulis tes unit, tetapi karena kami akan berbicara banyak tentang akses bersamaan ke dana nanti, menulis tolok ukur masuk akal.

Tolok ukur seperti pengujian unit, tetapi menyertakan loop yang menjalankan kode yang sama berkali-kali (dalam kasus kami, fund.Withdraw(1) ). Hal ini memungkinkan kerangka kerja untuk menentukan waktu berapa lama setiap iterasi berlangsung, merata-ratakan perbedaan sementara dari pencarian disk, cache miss, penjadwalan proses, dan faktor tak terduga lainnya.

Kerangka kerja pengujian ingin setiap tolok ukur berjalan setidaknya selama 1 detik (secara default). Untuk memastikan hal ini, ia akan memanggil benchmark beberapa kali, meneruskan nilai "jumlah iterasi" yang meningkat setiap kali (bidang bN ), hingga proses memakan waktu setidaknya satu detik.

Untuk saat ini, benchmark kami hanya akan menyetor sejumlah uang dan kemudian menariknya satu dolar sekaligus.

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()) } }

Sekarang mari kita jalankan:

 $ go test -bench . funding testing: warning: no tests to run PASS BenchmarkWithdrawals 2000000000 1.69 ns/op ok funding 3.576s

Itu berjalan dengan baik. Kami menjalankan dua miliar (!) iterasi, dan pemeriksaan terakhir pada saldo sudah benar. Kita dapat mengabaikan peringatan “tidak ada pengujian untuk dijalankan”, yang mengacu pada pengujian unit yang tidak kita tulis (dalam contoh pemrograman Go selanjutnya dalam tutorial ini, peringatan tersebut dihilangkan).

Akses Bersamaan di Go

Sekarang mari kita buat benchmark secara bersamaan, untuk memodelkan pengguna yang berbeda melakukan penarikan pada saat yang sama. Untuk melakukan itu, kita akan menelurkan sepuluh goroutine dan meminta masing-masing dari mereka menarik sepersepuluh dari uangnya.

Bagaimana kita menyusun beberapa goroutine bersamaan dalam bahasa Go?

Goroutine adalah blok bangunan dasar untuk konkurensi dalam bahasa Go. Mereka adalah utas hijau – utas ringan yang dikelola oleh runtime Go, bukan oleh sistem operasi. Ini berarti Anda dapat menjalankan ribuan (atau jutaan) dari mereka tanpa overhead yang signifikan. Goroutine dimunculkan dengan kata kunci go , dan selalu dimulai dengan fungsi (atau pemanggilan metode):

 // Returns immediately, without waiting for `DoSomething()` to complete go DoSomething()

Seringkali, kita ingin memunculkan fungsi satu kali singkat hanya dengan beberapa baris kode. Dalam hal ini kita dapat menggunakan penutupan alih-alih nama fungsi:

 go func() { // ... do stuff ... }() // Must be a function *call*, so remember the ()

Setelah semua goroutine kita muncul, kita perlu cara untuk menunggu mereka selesai. Kami dapat membangunnya sendiri menggunakan saluran , tetapi kami belum menemukannya, jadi itu akan dilewati.

Untuk saat ini, kita hanya dapat menggunakan tipe WaitGroup di perpustakaan standar Go, yang ada untuk tujuan ini. Kami akan membuat satu (disebut “ wg ”) dan memanggil wg.Add(1) sebelum menelurkan setiap pekerja, untuk melacak berapa banyak yang ada. Kemudian pekerja akan melaporkan kembali menggunakan wg.Done() . Sementara di goroutine utama, kita bisa mengatakan wg.Wait() untuk memblokir sampai setiap pekerja selesai.

Di dalam goroutine pekerja dalam contoh kita berikutnya, kita akan menggunakan defer untuk memanggil wg.Done() .

defer menerima panggilan fungsi (atau metode) dan menjalankannya segera sebelum fungsi saat ini kembali, setelah yang lainnya selesai. Ini berguna untuk pembersihan:

 func() { resource.Lock() defer resource.Unlock() // Do stuff with resource }()

Dengan cara ini kita dapat dengan mudah mencocokkan Unlock Lock dengan Kuncinya, agar mudah dibaca. Lebih penting lagi, fungsi yang ditangguhkan akan berjalan bahkan jika ada kepanikan di fungsi utama (sesuatu yang mungkin kita tangani melalui try-finally dalam bahasa lain).

Terakhir, fungsi yang ditangguhkan akan dijalankan dalam urutan kebalikan dari panggilannya, artinya kita dapat melakukan pembersihan bersarang dengan baik (mirip dengan idiom C dari goto s dan label s bersarang, tetapi jauh lebih rapi):

 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() // ... }()

Oke, jadi dengan semua yang dikatakan, inilah versi barunya:

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()) } }

Kita bisa memprediksi apa yang akan terjadi di sini. Semua pekerja akan mengeksekusi Withdraw di atas satu sama lain. Di dalamnya, f.balance -= amount akan membaca saldo, mengurangi satu, dan kemudian menulisnya kembali. Tetapi terkadang dua atau lebih pekerja akan membaca saldo yang sama, dan melakukan pengurangan yang sama, dan kita akan mendapatkan jumlah yang salah. Benar?

 $ go test -bench . funding BenchmarkWithdrawals 2000000000 2.01 ns/op ok funding 4.220s

Tidak, itu masih berlalu. Apa yang terjadi disini?

Ingatlah bahwa goroutine adalah utas hijau – mereka dikelola oleh runtime Go, bukan oleh OS. Runtime menjadwalkan goroutine di banyak utas OS yang tersedia. Pada saat menulis tutorial bahasa Go ini, Go tidak mencoba menebak berapa banyak utas OS yang harus digunakan, dan jika kita menginginkan lebih dari satu, kita harus mengatakannya. Terakhir, runtime saat ini tidak mendahului goroutine – goroutine akan terus berjalan hingga melakukan sesuatu yang menunjukkan bahwa goroutine siap untuk dihentikan (seperti berinteraksi dengan saluran).

Semua ini berarti bahwa meskipun tolok ukur kami sekarang bersamaan, itu tidak paralel . Hanya satu pekerja kami yang akan berjalan pada satu waktu, dan itu akan berjalan sampai selesai. Kita dapat mengubahnya dengan memberi tahu Go untuk menggunakan lebih banyak utas, melalui variabel lingkungan 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

Itu lebih baik. Sekarang kami jelas kehilangan beberapa penarikan kami, seperti yang kami harapkan.

Dalam contoh pemrograman Go ini, hasil dari beberapa goroutine paralel tidak menguntungkan.

Jadikan itu Server

Pada titik ini kami memiliki berbagai pilihan. Kita bisa menambahkan kunci mutex atau read-write eksplisit di sekitar dana. Kita bisa menggunakan bandingkan-dan-swap dengan nomor versi. Kita bisa habis-habisan dan menggunakan skema CRDT (mungkin mengganti bidang balance dengan daftar transaksi untuk setiap klien, dan menghitung saldo dari itu).

Tapi kita tidak akan melakukan hal-hal itu sekarang, karena itu berantakan atau menakutkan atau keduanya. Sebagai gantinya, kami akan memutuskan bahwa dana harus berupa server . Apa itu server? Itu adalah sesuatu yang Anda ajak bicara. Di Go, segala sesuatunya berbicara melalui saluran.

Saluran adalah mekanisme komunikasi dasar antara goroutine. Nilai dikirim ke saluran (dengan channel <- value ), dan dapat diterima di sisi lain (dengan value = <- channel ). Saluran adalah “goroutine safe”, artinya sejumlah goroutine dapat mengirim dan menerima dari mereka pada saat yang bersamaan.

Penyangga

Saluran komunikasi buffer dapat menjadi optimasi kinerja dalam keadaan tertentu, tetapi harus digunakan dengan sangat hati-hati (dan benchmarking!).

Namun, ada kegunaan untuk saluran buffer yang tidak secara langsung tentang komunikasi.

Misalnya, idiom pelambatan umum membuat saluran dengan (misalnya) ukuran buffer `10` dan kemudian mengirimkan sepuluh token ke dalamnya dengan segera. Sejumlah goroutine pekerja kemudian dimunculkan, dan masing-masing menerima token dari saluran sebelum mulai bekerja, dan mengirimkannya kembali sesudahnya. Kemudian, betapapun banyaknya pekerja, hanya sepuluh yang akan bekerja pada waktu yang sama.

Secara default, saluran Go tidak memiliki buffer . Ini berarti pengiriman nilai ke saluran akan diblokir sampai goroutine lain siap menerimanya segera. Go juga mendukung ukuran buffer tetap untuk saluran (menggunakan make(chan someType, bufferSize) ). Namun, untuk penggunaan normal, ini biasanya merupakan ide yang buruk .

Bayangkan sebuah server web untuk dana kita, di mana setiap permintaan melakukan penarikan. Ketika semuanya sangat sibuk, FundServer tidak akan dapat mengikuti, dan permintaan yang mencoba mengirim ke saluran perintahnya akan mulai diblokir dan menunggu. Pada saat itu kami dapat menerapkan jumlah permintaan maksimum di server, dan mengembalikan kode kesalahan yang masuk akal (seperti 503 Service Unavailable ) kepada klien di atas batas itu. Ini adalah perilaku terbaik yang mungkin dilakukan saat server kelebihan beban.

Menambahkan buffering ke saluran kami akan membuat perilaku ini kurang deterministik. Kami dapat dengan mudah berakhir dengan antrian panjang perintah yang belum diproses berdasarkan informasi yang dilihat klien jauh lebih awal (dan mungkin untuk permintaan yang telah habis waktu di hulu). Hal yang sama berlaku dalam banyak situasi lain, seperti menerapkan tekanan balik melalui TCP ketika penerima tidak dapat mengikuti pengirim.

Bagaimanapun, untuk contoh Go kami, kami akan tetap menggunakan perilaku unbuffered default.

Kami akan menggunakan saluran untuk mengirim perintah ke FundServer kami. Setiap pekerja benchmark akan mengirim perintah ke saluran, tetapi hanya server yang akan menerimanya.

Kami dapat mengubah jenis Dana kami menjadi implementasi server secara langsung, tetapi itu akan menjadi berantakan – kami akan mencampur penanganan konkurensi dan logika bisnis. Sebagai gantinya, kami akan membiarkan jenis Dana persis seperti itu, dan membuat FundServer menjadi pembungkus terpisah di sekitarnya.

Seperti server mana pun, wrapper akan memiliki loop utama di mana ia menunggu perintah, dan merespons masing-masing secara bergantian. Ada satu detail lagi yang perlu kita bahas di sini: Jenis perintah.

Diagram dana yang digunakan sebagai server dalam tutorial pemrograman Go ini.

Petunjuk

Kita bisa saja membuat saluran perintah kita mengambil *pointer* ke perintah (`chan *TransactionCommand`). Mengapa kita tidak?

Melewati pointer di antara goroutine berisiko, karena salah satu goroutine dapat memodifikasinya. Ini juga sering kurang efisien, karena goroutine lain mungkin berjalan pada inti CPU yang berbeda (artinya lebih banyak pembatalan cache).

Kapan pun memungkinkan, lebih suka meneruskan nilai-nilai polos.

Di bagian berikutnya di bawah, kami akan mengirimkan beberapa perintah berbeda, masing-masing dengan tipe structnya sendiri. Kami ingin saluran Perintah server menerima salah satu dari mereka. Dalam bahasa OOP, kita mungkin melakukan ini melalui polimorfisme: Minta saluran mengambil superclass, di mana tipe perintah individu adalah subclass. Di Go, kami menggunakan antarmuka sebagai gantinya.

Antarmuka adalah sekumpulan tanda tangan metode. Setiap jenis yang mengimplementasikan semua metode tersebut dapat diperlakukan sebagai antarmuka itu (tanpa dideklarasikan untuk melakukannya). Untuk menjalankan pertama kami, struct perintah kami tidak akan benar-benar mengekspos metode apa pun, jadi kami akan menggunakan antarmuka kosong, interface{} . Karena tidak memiliki persyaratan, nilai apa pun (termasuk nilai primitif seperti bilangan bulat) memenuhi antarmuka kosong. Ini tidak ideal – kami hanya ingin menerima struct perintah – tetapi kami akan kembali lagi nanti.

Untuk saat ini, mari kita mulai dengan scaffolding untuk server Go kita:

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 } }

Sekarang mari tambahkan beberapa tipe struct Golang untuk perintah:

 type WithdrawCommand struct { Amount int } type BalanceCommand struct { Response chan int }

WithdrawCommand hanya berisi jumlah yang akan ditarik. Tidak ada respon. BalanceCommand memang memiliki respons, jadi ini menyertakan saluran untuk mengirimnya. Ini memastikan bahwa tanggapan akan selalu sampai ke tempat yang tepat, bahkan jika dana kami kemudian memutuskan untuk merespons dengan tidak teratur.

Sekarang kita dapat menulis loop utama server:

 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)) } } }

Hmm. Itu agak jelek. Kami mengaktifkan jenis perintah, menggunakan pernyataan jenis, dan mungkin mogok. Mari terus maju dan perbarui benchmark untuk menggunakan server.

 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) } }

Itu agak jelek juga, terutama ketika kami memeriksa saldo. Sudahlah. Mari kita coba:

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 5000000 465 ns/op ok funding 2.822s

Jauh lebih baik, kami tidak lagi kehilangan penarikan. Tetapi kodenya semakin sulit dibaca, dan ada masalah yang lebih serius. Jika kami pernah mengeluarkan BalanceCommand dan kemudian lupa membaca tanggapannya, server dana kami akan memblokir selamanya saat mencoba mengirimkannya. Mari kita bersihkan sedikit.

Jadikan itu Layanan

Server adalah sesuatu yang Anda ajak bicara. Apa itu layanan? Layanan adalah sesuatu yang Anda ajak bicara dengan API . Alih-alih membuat kode klien bekerja dengan saluran perintah secara langsung, kami akan membuat saluran tidak diekspor (pribadi) dan membungkus perintah yang tersedia dalam fungsi.

 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 } }

Sekarang tolok ukur kami hanya dapat mengatakan server.Withdraw(1) dan balance := server.Balance() , dan kecil kemungkinan untuk secara tidak sengaja mengirimkan perintah yang tidak valid atau lupa membaca tanggapan.

Berikut adalah tampilan penggunaan dana sebagai layanan dalam contoh program bahasa Go ini.

Masih ada banyak boilerplate tambahan untuk perintah, tapi kami akan kembali lagi nanti.

Transaksi

Ujung-ujungnya uang selalu habis. Mari kita sepakat bahwa kita akan berhenti menarik ketika dana kita turun ke sepuluh dolar terakhir, dan membelanjakan uang itu untuk pizza bersama untuk merayakan atau bersimpati. Patokan kami akan mencerminkan ini:

 // 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) }

Kali ini kita benar-benar bisa memprediksi hasilnya.

 $ 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

Kami kembali ke tempat kami memulai – beberapa pekerja dapat membaca saldo sekaligus, lalu semuanya memperbaruinya. Untuk mengatasinya, kita dapat menambahkan beberapa logika dalam dana itu sendiri, seperti properti minimumBalance , atau menambahkan perintah lain yang disebut WithdrawIfOverXDollars . Ini adalah kedua ide yang mengerikan. Kesepakatan kami adalah di antara kami sendiri, bukan milik dana tersebut. Kita harus menyimpannya dalam logika aplikasi.

Yang kita butuhkan sebenarnya adalah transaction , dalam arti yang sama dengan transaksi database. Karena layanan kami hanya menjalankan satu perintah pada satu waktu, ini sangat mudah. Kami akan menambahkan perintah Transact yang berisi panggilan balik (penutupan). Server akan mengeksekusi panggilan balik itu di dalam goroutinenya sendiri, meneruskan Fund mentahnya. Panggilan balik kemudian dapat dengan aman melakukan apa pun yang diinginkannya dengan Fund .

Semafor dan kesalahan

Dalam contoh berikut ini kita melakukan dua hal kecil yang salah.

Pertama, kami menggunakan saluran `Selesai` sebagai semaphore untuk memberi tahu kode panggilan saat transaksinya selesai. Tidak apa-apa, tetapi mengapa jenis salurannya `bool`? Kami hanya akan mengirim `true` ke dalamnya yang berarti "selesai" (apa artinya mengirim `false`?). Apa yang sebenarnya kita inginkan adalah nilai keadaan tunggal (nilai yang tidak memiliki nilai?). Di Go, kita bisa melakukannya menggunakan tipe struct kosong: `struct{}`. Ini juga memiliki keuntungan menggunakan lebih sedikit memori. Dalam contoh kita akan tetap menggunakan `bool` agar tidak terlihat terlalu menakutkan.

Kedua, panggilan balik transaksi kami tidak mengembalikan apa pun. Seperti yang akan kita lihat sebentar lagi, kita bisa mendapatkan nilai dari panggilan balik ke dalam kode panggilan menggunakan trik lingkup. Namun, transaksi dalam sistem nyata kadang-kadang mungkin gagal, jadi konvensi Go akan membuat transaksi mengembalikan `error` (dan kemudian memeriksa apakah `nil` dalam kode panggilan).

Kami juga tidak melakukan itu untuk saat ini, karena kami tidak memiliki kesalahan untuk dibuat.
 // 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 // ... } } }

Callback transaksi kami tidak secara langsung mengembalikan apa pun, tetapi bahasa Go memudahkan untuk mendapatkan nilai dari penutupan secara langsung, jadi kami akan melakukannya di benchmark untuk menyetel flag pizzaTime saat uang hampir habis:

 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 } }

Dan periksa apakah itu berfungsi:

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 5000000 775 ns/op ok funding 4.637s

Tidak ada Tapi transaksi

Anda mungkin telah melihat kesempatan untuk membersihkan beberapa hal lagi sekarang. Karena kita memiliki perintah Transact generik, kita tidak membutuhkan WithdrawCommand atau BalanceCommand lagi. Kami akan menulis ulang mereka dalam hal transaksi:

 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) }) }

Sekarang satu-satunya perintah yang diambil server adalah TransactionCommand , jadi kita bisa membuang seluruh kekacauan interface{} dalam implementasinya, dan membuatnya hanya menerima perintah transaksi:

 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 } }

Jauh lebih baik.

Ada langkah terakhir yang bisa kita ambil di sini. Terlepas dari fungsi kemudahannya untuk Balance dan Withdraw , implementasi layanan tidak lagi terikat pada Fund . Alih-alih mengelola Fund , itu bisa mengelola interface{} dan digunakan untuk membungkus apa pun . Akan tetapi, setiap callback transaksi kemudian harus mengonversi interface{} kembali ke nilai sebenarnya:

 type Transactor func(interface{}) server.Transact(func(managedValue interface{}) { fund := managedValue.(*Fund) // Do stuff with fund ... })

Ini jelek dan rawan kesalahan. Yang benar-benar kami inginkan adalah waktu kompilasi generik, sehingga kami dapat "mem-template" server untuk tipe tertentu (seperti *Fund ).

Sayangnya, Go belum mendukung obat generik – belum. Diharapkan untuk tiba pada akhirnya, setelah seseorang mengetahui beberapa sintaks dan semantik yang masuk akal untuk itu. Sementara itu, desain antarmuka yang cermat sering kali menghilangkan kebutuhan akan obat generik, dan jika tidak, kita dapat bertahan dengan pernyataan tipe (yang diperiksa saat runtime).

Sudahkah kita selesai?

Ya.

Yah, oke, tidak.

Contohnya:

  • Kepanikan dalam transaksi akan mematikan seluruh layanan.

  • Tidak ada batas waktu. Transaksi yang tidak pernah kembali akan memblokir layanan selamanya.

  • Jika Dana kami menumbuhkan beberapa bidang baru dan transaksi macet di tengah jalan memperbaruinya, kami akan memiliki status yang tidak konsisten.

  • Transaksi dapat membocorkan objek Fund yang dikelola, yang tidak baik.

  • Tidak ada cara yang masuk akal untuk melakukan transaksi di banyak dana (seperti menarik dari satu dana dan menyetor ke dana lain). Kami tidak bisa hanya menyarangkan transaksi kami karena itu akan memungkinkan kebuntuan.

  • Menjalankan transaksi secara asinkron sekarang membutuhkan goroutine baru dan banyak main-main. Terkait, kami mungkin ingin dapat membaca status Fund terbaru dari tempat lain saat transaksi jangka panjang sedang berlangsung.

Dalam tutorial bahasa pemrograman Go berikutnya, kita akan melihat beberapa cara untuk mengatasi masalah ini.

Terkait: Logika yang Terstruktur dengan Baik: Tutorial OOP Golang