10 kesalahan paling umum yang saya temui di Go-proyek

Posting ini adalah kesalahan saya yang paling umum yang saya temui di Go-proyek. Urutan tidak masalah.

gambar

Nilai Enum yang tidak diketahui


Mari kita lihat contoh sederhana:

type Status uint32 const ( StatusOpen Status = iota StatusClosed StatusUnknown ) 

Di sini kita membuat enumerator menggunakan iota, yang akan mengarah ke keadaan ini:

 StatusOpen = 0 StatusClosed = 1 StatusUnknown = 2 

Sekarang mari kita bayangkan bahwa jenis Status ini adalah bagian dari permintaan JSON yang akan dikemas / dibongkar. Kita dapat merancang struktur berikut:

 type Request struct { ID int `json:"Id"` Timestamp int `json:"Timestamp"` Status Status `json:"Status"` } 

Lalu kami mendapatkan hasil permintaan ini:

 { "Id": 1234, "Timestamp": 1563362390, "Status": 0 } 

Secara umum, tidak ada yang istimewa - Status akan dibuka ke StatusOpen.
Sekarang, mari kita dapatkan jawaban lain di mana nilai status tidak disetel:

 { "Id": 1235, "Timestamp": 1563362390 } 

Dalam kasus ini, bidang Status dari struktur Permintaan akan diinisialisasi ke nol (untuk uint32 itu adalah 0). Karenanya, kami kembali mendapatkan StatusOpen alih-alih StatusUnknown.

Dalam hal ini, yang terbaik adalah menetapkan nilai enumerator yang tidak diketahui terlebih dahulu - yaitu. 0:

 type Status uint32 const ( StatusUnknown Status = iota StatusOpen StatusClosed ) 

Jika statusnya bukan bagian dari permintaan JSON, itu akan diinisialisasi dalam StatusUnknown, seperti yang kita harapkan.

Benchmarking


Pembandingan yang benar cukup sulit. Terlalu banyak faktor yang dapat mempengaruhi hasil.

Satu kesalahan umum sedang diakali oleh optimisasi kompiler. Mari kita lihat contoh spesifik dari perpustakaan teivah / bitvector :

 func clear(n uint64, i, j uint8) uint64 { return (math.MaxUint64<<j | ((1 << i) - 1)) & n } 

Fungsi ini membersihkan bit dalam rentang tertentu. Kami dapat menguji kinerja dengan cara ini:

 func BenchmarkWrong(b *testing.B) { for i := 0; i < bN; i++ { clear(1221892080809121, 10, 63) } } 

Dalam pengujian ini, kompiler akan melihat bahwa clear tidak memanggil fungsi lain, jadi ia hanya menyematkan apa adanya. Setelah dibangun, kompiler akan melihat bahwa tidak ada efek samping yang terjadi. Dengan demikian, panggilan yang jelas hanya akan dihapus, yang akan menyebabkan hasil yang tidak akurat.

Salah satu solusinya adalah mengatur hasil ke variabel global, seperti ini:

 var result uint64 func BenchmarkCorrect(b *testing.B) { var r uint64 for i := 0; i < bN; i++ { r = clear(1221892080809121, 10, 63) } result = r } 

Di sini kompiler tidak akan tahu apakah panggilan itu menciptakan efek samping. Karena itu, tolok ukurnya akan akurat.

Pointer! Pointer ada di mana-mana!


Melewati variabel dengan nilai akan membuat salinan variabel ini. Saat melewati pointer, cukup salin alamat ke memori.

Akibatnya, melewati pointer akan selalu lebih cepat, bukan?

Jika Anda berpikir begitu, lihat contoh ini . Ini adalah patokan untuk struktur data 0,3 KB yang pertama kali kami kirim dan terima dengan penunjuk, lalu menurut nilainya. 0,3 KB sedikit - tentang struktur data yang biasa kami kerjakan setiap hari menempati sebanyak itu.

Ketika saya menjalankan tes ini di lingkungan lokal, transmisi nilai demi nilai lebih dari 4 kali lebih cepat. Sangat tak terduga, bukan?

Penjelasan hasil ini terkait dengan pemahaman tentang bagaimana manajemen memori terjadi di Go. Saya tidak bisa menjelaskannya secemerlang William Kennedy , tapi mari kita simpulkan secara singkat.

Sebuah variabel dapat ditempatkan di heap atau stack:
  • Tumpukan berisi variabel saat ini dari program ini. Segera setelah fungsi kembali, variabel muncul dari tumpukan.
  • Tumpukan berisi variabel umum (variabel global, dll.).

Mari kita lihat contoh sederhana di mana kita mengembalikan nilai:

 func getFooValue() foo { var result foo // Do something return result } 

Di sini variabel hasil dibuat oleh goroutine saat ini. Variabel ini didorong ke tumpukan saat ini. Segera setelah fungsi kembali, klien akan menerima salinan variabel ini. Variabel itu sendiri muncul dari tumpukan. Itu masih ada dalam memori sampai variabel lain ditimpa, tetapi tidak dapat diakses lagi.
Sekarang contoh yang sama, tetapi dengan pointer:

 func getFooPointer() *foo { var result foo // Do something return &result } 

Variabel hasil masih dibuat oleh goroutine saat ini, tetapi klien akan menerima pointer (salinan alamat variabel). Jika variabel hasil muncul dari tumpukan, klien fungsi ini tidak akan dapat mengaksesnya.

Dalam skenario ini, kompilator Go akan menampilkan variabel hasil ke tempat variabel dapat dibagi, yaitu dalam banyak.

Script lain untuk melewati pointer:

 func main() { p := &foo{} f(p) } 

Karena kita memanggil f dalam program yang sama, variabel p tidak perlu ditumpuk. Ini hanya didorong ke stack, dan subfungsi dapat mengaksesnya.

Misalnya, dengan cara ini sepotong diperoleh dalam metode Baca dari io.Reader. Mengembalikan slice (yang merupakan pointer) meletakkannya di heap.

Mengapa tumpukan itu begitu cepat? Ada dua alasan:
  • Tidak perlu menggunakan pengumpul sampah di tumpukan. Seperti yang telah kami katakan, variabel hanya didorong setelah dibuat, dan kemudian muncul dari tumpukan ketika fungsi kembali. Tidak perlu membangkitkan proses yang rumit untuk mengembalikan variabel yang tidak digunakan, dll.
  • Tumpukan milik satu goroutine, sehingga penyimpanan variabel tidak perlu disinkronkan, seperti yang terjadi dengan penyimpanan di heap, yang juga mengarah pada peningkatan kinerja.

Kesimpulannya, saat kita membuat fungsi, tindakan default kita seharusnya menggunakan nilai alih-alih pointer. Pointer hanya boleh digunakan jika kita ingin membagikan variabel.

Juga, jika kita menderita masalah kinerja, salah satu optimasi yang mungkin dilakukan adalah memeriksa apakah pointer membantu dalam situasi tertentu? Apakah kompiler menampilkan variabel ke heap dapat ditemukan dengan perintah berikut:
 go build -gcflags "-m -m" 
.
Tetapi, sekali lagi, untuk sebagian besar tugas kita sehari-hari, menggunakan nilai adalah yang terbaik.

Batalkan untuk / beralih atau untuk / pilih


Apa yang terjadi dalam contoh berikut jika f mengembalikan true?

 for { switch f() { case true: break case false: // Do something } } 

Kami menyebutnya istirahat. Hanya istirahat ini yang memecah saklar, bukan untuk loop.

Masalah yang sama di sini:

 for { select { case <-ch: // Do something case <-ctx.Done(): break } } 

Break dikaitkan dengan pernyataan pilih, bukan untuk loop.

Salah satu solusi yang mungkin untuk mengganggu / beralih atau untuk / pilih adalah dengan menggunakan label:

 loop: for { select { case <-ch: // Do something case <-ctx.Done(): break loop } } 

Menangani kesalahan


Go masih muda, terutama di bidang penanganan kesalahan. Mengatasi kekurangan ini adalah salah satu inovasi yang paling diantisipasi di Go 2.

Perpustakaan standar saat ini (sebelum Go 1.13) hanya menawarkan fungsi untuk membangun kesalahan. Oleh karena itu, akan menarik untuk melihat pada paket pkg / error .

Perpustakaan ini adalah cara yang baik untuk mengikuti aturan yang tidak selalu dihormati:
Kesalahan harus diproses hanya sekali. Kesalahan logging adalah penanganan kesalahan
. Dengan demikian, kesalahan harus dicatat atau dilemparkan lebih tinggi.

Dalam pustaka standar saat ini, prinsip ini sulit untuk diamati, karena kita mungkin ingin menambahkan konteks ke kesalahan dan memiliki semacam hierarki.

Mari kita lihat contoh dengan panggilan REST yang mengarah ke kesalahan basis data:

 unable to server HTTP POST request for customer 1234 |_ unable to insert customer contract abcd |_ unable to commit transaction 

Jika kita menggunakan pkg / kesalahan, kita dapat melakukan hal berikut:

 func postHandler(customer Customer) Status { err := insert(customer.Contract) if err != nil { log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID) return Status{ok: false} } return Status{ok: true} } func insert(contract Contract) error { err := dbQuery(contract) if err != nil { return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID) } return nil } func dbQuery(contract Contract) error { // Do something then fail return errors.New("unable to commit transaction") } 

Kesalahan awal (jika tidak dikembalikan oleh perpustakaan eksternal) dapat dibuat menggunakan kesalahan. Baru. Lapisan tengah, masukkan, bungkus kesalahan ini, tambahkan lebih banyak konteks untuknya. Kemudian orang tua mencatatnya. Dengan demikian, setiap level akan mengembalikan atau memproses kesalahan.

Kami mungkin juga ingin menemukan penyebab kesalahan, misalnya, untuk menelepon kembali. Misalkan kita memiliki paket db dari perpustakaan eksternal yang memiliki akses ke database. Perpustakaan ini dapat mengembalikan kesalahan sementara yang disebut db.DBError. Untuk menentukan apakah kita perlu mencoba lagi, kita harus menentukan penyebab kesalahan:

 func postHandler(customer Customer) Status { err := insert(customer.Contract) if err != nil { switch errors.Cause(err).(type) { default: log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID) return Status{ok: false} case *db.DBError: return retry(customer) } } return Status{ok: true} } func insert(contract Contract) error { err := db.dbQuery(contract) if err != nil { return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID) } return nil } 

Ini dilakukan dengan menggunakan kesalahan. Penyebab, yang juga termasuk dalam pkg / kesalahan :

Salah satu kesalahan umum yang saya temui adalah penggunaan pkg / kesalahan hanya sebagian. Pemeriksaan kesalahan, misalnya, dilakukan sebagai berikut:

 switch err.(type) { default: log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID) return Status{ok: false} case *db.DBError: return retry(customer) } 

Dalam contoh ini, jika db.DBError dibungkus, itu tidak akan pernah membuat panggilan kedua.

Inisialisasi irisan


Terkadang kita tahu berapa panjang akhir irisan itu. Sebagai contoh, misalkan kita ingin mengubah irisan Foo menjadi irisan Bar, yang berarti kedua irisan ini akan memiliki panjang yang sama.

Saya sering menemukan irisan yang diinisialisasi dengan cara ini:

 var bars []Bar bars := make([]Bar, 0) 

Iris bukanlah struktur magis. Di bawah tenda, ia menerapkan strategi untuk meningkatkan ukuran jika tidak ada lagi ruang kosong. Dalam hal ini, array baru dibuat secara otomatis (dengan kapasitas lebih besar), dan semua elemen disalin ke sana.

Sekarang mari kita bayangkan bahwa kita perlu mengulangi operasi peningkatan ukuran ini beberapa kali, karena [] Foo kita mengandung ribuan elemen. Kompleksitas algoritma penyisipan akan tetap O (1), tetapi dalam praktiknya ini akan mempengaruhi kinerja.

Karena itu, jika kita mengetahui panjang akhir, kita dapat:

  • Inisialisasi dengan panjang yang telah ditentukan:

 func convert(foos []Foo) []Bar { bars := make([]Bar, len(foos)) for i, foo := range foos { bars[i] = fooToBar(foo) } return bars } 

  • Atau inisialisasi dengan panjang 0 dan kapasitas yang telah ditentukan:

 func convert(foos []Foo) []Bar { bars := make([]Bar, 0, len(foos)) for _, foo := range foos { bars = append(bars, fooToBar(foo)) } return bars } 

Apa pilihan terbaik? Yang pertama sedikit lebih cepat. Namun, Anda dapat memilih yang terakhir, karena lebih konsisten: terlepas dari apakah kami tahu ukuran awal, menambahkan elemen di akhir irisan dilakukan menggunakan append.

Manajemen konteks


context.Context sering disalahpahami oleh pengembang. Menurut dokumentasi resmi:
Konteksnya membawa tenggat waktu, membatalkan sinyal, dan nilai-nilai lain melintasi batas API.
Deskripsi ini cukup umum, karena itu dapat membingungkan programmer bagaimana menggunakannya dengan benar.

Mari kita coba mencari tahu. Konteks dapat membawa:
  • Batas waktu - berarti durasi (misalnya, 250 ms) atau tanggal-waktu (misalnya, 2019-01-08 01:00:00), yang menurut kami percaya bahwa jika tercapai, tindakan saat ini harus dibatalkan (permintaan I / O ), menunggu input saluran, dll.).
  • Batalkan sinyal (pada dasarnya <-chan struct {}). Di sini perilakunya mirip. Segera setelah kami menerima sinyal, kami harus menghentikan pekerjaan saat ini. Sebagai contoh, katakanlah kita mendapatkan dua permintaan. Satu untuk memasukkan data, dan yang lainnya untuk membatalkan permintaan pertama (karena tidak lagi relevan, misalnya). Ini dapat dicapai dengan menggunakan konteks yang dibatalkan pada panggilan pertama, yang kemudian akan dibatalkan segera setelah kami menerima permintaan kedua.
  • Daftar kunci / nilai (keduanya berdasarkan pada antarmuka {} type).

Dua poin lagi. Pertama, konteksnya komposable. Karenanya, kami mungkin memiliki konteks yang memuat tenggat waktu dan daftar kunci / nilai, misalnya. Selain itu, beberapa goroutine dapat berbagi konteks yang sama, sehingga sinyal batal berpotensi menghentikan beberapa pekerjaan.

Kembali ke topik kita, inilah kesalahan yang saya temui.

Aplikasi Go didasarkan pada urfave / cli (jika Anda tidak tahu, ini adalah perpustakaan yang baik untuk membuat aplikasi baris perintah di Go). Setelah diluncurkan, pengembang mewarisi semacam konteks aplikasi. Ini berarti bahwa ketika aplikasi dihentikan, perpustakaan akan menggunakan konteks untuk mengirim sinyal batal.

Saya perhatikan bahwa konteks ini ditransmisikan secara langsung, misalnya, ketika titik akhir gRPC dipanggil. Ini sama sekali bukan yang kita butuhkan.

Sebagai gantinya, kami ingin memberi tahu perpustakaan gRPC: harap batalkan permintaan saat aplikasi dihentikan, atau setelah 100 ms, misalnya.

Untuk mencapai ini, kita cukup membuat konteks gabungan. Jika orang tua adalah nama konteks aplikasi (dibuat oleh urfave / cli ), maka kita bisa melakukan ini:

 ctx, cancel := context.WithTimeout(parent, 100 * time.Millisecond) response, err := grpcClient.Send(ctx, request) 

Konteksnya tidak begitu sulit untuk dipahami, dan, menurut saya, ini adalah salah satu fitur terbaik dari bahasa tersebut.

Tidak menggunakan opsi -race


Menguji aplikasi Go tanpa opsi -race adalah bug yang selalu saya temui.

Seperti yang ditulis dalam artikel ini , meskipun Go " dirancang untuk membuat pemrograman paralel lebih sederhana dan lebih sedikit kesalahan, " kami masih sangat menderita dari masalah konkurensi.

Jelas, detektor ras Go tidak akan membantu dengan masalah apa pun. Namun, ini adalah alat yang berharga, dan kita harus selalu memasukkannya saat menguji aplikasi kita.

Menggunakan nama file sebagai input


Kesalahan umum lainnya adalah meneruskan nama file ke suatu fungsi.

Misalkan kita perlu mengimplementasikan fungsi untuk menghitung jumlah baris kosong dalam file. Implementasi paling alami akan terlihat seperti ini:

 func count(filename string) (int, error) { file, err := os.Open(filename) if err != nil { return 0, errors.Wrapf(err, "unable to open %s", filename) } defer file.Close() scanner := bufio.NewScanner(file) count := 0 for scanner.Scan() { if scanner.Text() == "" { count++ } } return count, nil } 

Nama file ditetapkan sebagai input, jadi kami membukanya dan kemudian menerapkan logika kami, bukan?

Sekarang anggaplah kita ingin membahas fungsi ini dengan unit test. Kami akan menguji dengan file biasa, file kosong, file dengan jenis pengkodean yang berbeda, dll. Mungkin sangat sulit untuk mengelolanya.

Selain itu, jika kita ingin menerapkan logika yang sama, misalnya, untuk badan HTTP, kita perlu membuat fungsi lain untuk ini.

Go hadir dengan dua abstraksi hebat: io.Reader dan io.Writer. Alih-alih meneruskan nama file, kita bisa meneruskan io.Reader, yang akan mengabstraksi sumber data.
Apakah ini file? Badan HTTP? Buffer byte? Itu tidak masalah, karena kita masih akan menggunakan metode Baca yang sama.

Dalam kasus kami, kami bahkan dapat menyangga input untuk membacanya baris demi baris. Untuk melakukan ini, Anda dapat menggunakan bufio.Reader dan metode ReadLine-nya:

 func count(reader *bufio.Reader) (int, error) { count := 0 for { line, _, err := reader.ReadLine() if err != nil { switch err { default: return 0, errors.Wrapf(err, "unable to read") case io.EOF: return count, nil } } if len(line) == 0 { count++ } } } 

Sekarang tanggung jawab untuk membuka file telah didelegasikan ke klien hitungan:

 file, err := os.Open(filename) if err != nil { return errors.Wrapf(err, "unable to open %s", filename) } defer file.Close() count, err := count(bufio.NewReader(file)) 

Dalam implementasi kedua, suatu fungsi dapat dipanggil terlepas dari sumber data aktual. Sementara itu, ini akan memudahkan pengujian unit kami, karena kami dapat dengan mudah membuat bufio.Reader dari baris:

 count, err := count(bufio.NewReader(strings.NewReader("input"))) 

Goroutine dan variabel siklus


Kesalahan umum terakhir yang saya temui adalah ketika menggunakan goroutine dengan variabel loop.

Apa yang akan menjadi kesimpulan dari contoh berikut?

 ints := []int{1, 2, 3} for _, i := range ints { go func() { fmt.Printf("%v\n", i) }() } 

1 2 3 secara acak? Tidak.

Dalam contoh ini, setiap goroutine menggunakan instance variabel yang sama, sehingga akan menghasilkan 3 3 3 (kemungkinan besar).

Ada dua solusi untuk masalah ini. Yang pertama adalah meneruskan nilai variabel i ke penutup (fungsi internal):

 ints := []int{1, 2, 3} for _, i := range ints { go func(i int) { fmt.Printf("%v\n", i) }(i) } 

Yang kedua adalah membuat variabel lain di dalam for loop:

 ints := []int{1, 2, 3} for _, i := range ints { i := i go func() { fmt.Printf("%v\n", i) }() } 

Menetapkan i: = i mungkin tampak sedikit aneh, tetapi desain ini sangat valid. Berada dalam satu lingkaran berarti berada dalam ruang lingkup yang berbeda. Oleh karena itu, i: = i membuat instance lain dari variabel i. Tentu saja, kita dapat menyebutnya dengan nama yang berbeda agar mudah dibaca.

Jika Anda mengetahui kesalahan umum lainnya, silakan menuliskannya di komentar.

Source: https://habr.com/ru/post/id461051/


All Articles