Go Praktis: Kiat untuk Menulis Program yang Didukung di Dunia Nyata

Artikel ini berfokus pada praktik terbaik untuk menulis kode Go. Ini disusun dalam gaya presentasi, tetapi tanpa slide biasa. Kami akan mencoba menerangkan secara singkat dan jelas setiap item.

Pertama, Anda perlu menyepakati apa arti praktik terbaik untuk bahasa pemrograman. Di sini Anda dapat mengingat kata-kata Russ Cox, direktur teknis Go:

Rekayasa perangkat lunak adalah apa yang terjadi pada pemrograman, jika Anda menambahkan faktor waktu dan programmer lainnya.

Dengan demikian, Russ membedakan antara konsep pemrograman dan rekayasa perangkat lunak . Dalam kasus pertama, Anda menulis sebuah program untuk diri sendiri, di saat Anda membuat produk yang akan digunakan oleh programmer lain dari waktu ke waktu. Insinyur datang dan pergi. Tim tumbuh atau menyusut. Fitur baru ditambahkan dan bug diperbaiki. Ini adalah sifat pengembangan perangkat lunak.

Isi



1. Prinsip dasar


Saya mungkin salah satu pengguna pertama Go di antara Anda, tetapi ini bukan pendapat pribadi saya. Prinsip-prinsip dasar ini mendasari Go sendiri:

  1. Kesederhanaan
  2. Keterbacaan
  3. Produktivitas

Catatan Harap dicatat, saya tidak menyebutkan "kinerja" atau "konkurensi". Ada bahasa yang lebih cepat daripada Go, tetapi tentu saja tidak dapat dibandingkan dengan kesederhanaan. Ada bahasa yang menempatkan paralelisme sebagai prioritas utama, tetapi mereka tidak dapat dibandingkan dalam hal keterbacaan atau produktivitas pemrograman.

Kinerja dan konkurensi adalah atribut penting, tetapi tidak sepenting kesederhanaan, keterbacaan, dan produktivitas.

Kesederhanaan


“Kesederhanaan adalah prasyarat untuk keandalan” - Edsger Dijkstra

Mengapa berjuang untuk kesederhanaan? Mengapa penting agar program Go sederhana?

Kita masing-masing menemukan kode yang tidak bisa dipahami, bukan? Ketika Anda takut untuk melakukan perubahan karena itu akan merusak bagian lain dari program yang Anda tidak mengerti dan tidak tahu bagaimana cara memperbaikinya. Inilah kesulitannya.

“Ada dua cara untuk merancang perangkat lunak: yang pertama adalah membuatnya sangat sederhana sehingga tidak ada kekurangan yang jelas, dan yang kedua adalah membuatnya sangat rumit sehingga tidak ada kekurangan yang jelas. Yang pertama jauh lebih sulit. ” - C.E. R. Hoar

Kompleksitas mengubah perangkat lunak yang andal menjadi tidak dapat diandalkan. Kompleksitas adalah yang membunuh proyek perangkat lunak. Oleh karena itu, kesederhanaan adalah tujuan utama Go. Program apa pun yang kami tulis, harus sederhana.

1.2. Keterbacaan


“Keterbacaan adalah bagian integral dari kemampuan pemeliharaan” - Mark Reinhold, JVM Conference, 2018

Mengapa penting agar kode dapat dibaca? Mengapa kita harus berusaha agar mudah dibaca?

"Program harus ditulis untuk orang, dan mesin hanya menjalankannya" - Hal Abelson dan Gerald Sassman, "Struktur dan interpretasi program komputer"

Tidak hanya Go program, tetapi umumnya semua perangkat lunak ditulis oleh orang untuk orang. Fakta bahwa mesin juga memproses kode adalah sekunder.

Sekali kode tertulis akan berulang kali dibaca oleh orang: ratusan, jika tidak ribuan kali.

"Keterampilan paling penting bagi seorang programmer adalah kemampuan untuk mengkomunikasikan ide secara efektif." - Gaston Horker

Keterbacaan adalah kunci untuk memahami apa yang dilakukan suatu program. Jika Anda tidak dapat memahami kode tersebut, bagaimana cara mempertahankannya? Jika perangkat lunak tidak dapat didukung, itu akan ditulis ulang; dan ini mungkin terakhir kali perusahaan Anda menggunakan Go.

Jika Anda menulis program untuk diri sendiri, lakukan apa yang sesuai untuk Anda. Tetapi jika ini adalah bagian dari proyek bersama atau program akan digunakan cukup lama untuk mengubah persyaratan, fungsi atau lingkungan di mana ia bekerja, maka tujuan Anda adalah membuat program tersebut dapat dipertahankan.

Langkah pertama untuk menulis perangkat lunak yang didukung adalah memastikan kodenya jelas.

1.3. Produktivitas


"Desain adalah seni mengatur kode sehingga berfungsi hari ini, tetapi selalu mendukung perubahan." - Sandy Mets

Sebagai prinsip dasar terakhir saya ingin menyebutkan produktivitas pengembang. Ini adalah topik besar, tetapi turun ke rasio: berapa banyak waktu yang Anda habiskan untuk pekerjaan yang bermanfaat, dan berapa banyak - menunggu respons dari alat atau pengembaraan tanpa harapan dalam basis kode yang tidak dapat dipahami. Para programmer harus merasa bahwa mereka dapat menangani banyak pekerjaan.

Ini adalah lelucon bahwa bahasa Go dikembangkan saat program C ++ sedang dikompilasi. Kompilasi cepat adalah fitur utama Go dan faktor kunci dalam menarik pengembang baru. Meskipun kompiler sedang diperbaiki, secara umum, kompilasi menit dalam bahasa lain membutuhkan waktu beberapa detik untuk Go. Jadi pengembang Go merasa sama produktifnya dengan programmer dalam bahasa dinamis, tetapi tanpa masalah dengan keandalan bahasa tersebut.

Jika kita berbicara secara mendasar tentang produktivitas pengembang, maka programmer Go memahami bahwa membaca kode pada dasarnya lebih penting daripada menulisnya. Dalam logika ini, Go bahkan melangkah lebih jauh dengan menggunakan alat untuk memformat semua kode dalam gaya tertentu. Ini menghilangkan sedikit kesulitan dalam mempelajari dialek spesifik dari proyek tertentu dan membantu mengidentifikasi kesalahan karena mereka hanya terlihat salah dibandingkan dengan kode biasa.

Pemrogram Go tidak menghabiskan berhari-hari men-debug kesalahan kompilasi aneh, skrip build kompleks, atau menyebarkan kode dalam lingkungan produksi. Dan yang paling penting, mereka tidak membuang waktu untuk mencoba memahami apa yang ditulis oleh seorang kolega.

Ketika pengembang Go berbicara tentang skalabilitas , artinya produktivitas.

2. Pengidentifikasi


Topik pertama yang akan kita bahas - pengidentifikasi , adalah sinonim untuk nama : nama variabel, fungsi, metode, jenis, paket, dan sebagainya.

"Nama Buruk Adalah Gejala Desain Buruk" - Dave Cheney

Dengan sintaks yang terbatas pada Go, nama-nama objek memiliki dampak besar pada keterbacaan program. Keterbacaan adalah faktor kunci dalam kode yang baik, jadi memilih nama yang baik sangat penting.

2.1. Nama pengidentifikasi berdasarkan kejelasan daripada singkatnya


“Penting bahwa kodenya jelas. Apa yang dapat Anda lakukan dalam satu baris, Anda harus lakukan dalam tiga. ” - Ukia Smith

Go tidak dioptimalkan untuk rumit satu-liner atau jumlah minimum garis dalam suatu program. Kami tidak mengoptimalkan ukuran kode sumber pada disk, atau waktu yang diperlukan untuk mengetik program di editor.

“Nama yang bagus seperti lelucon yang bagus. Jika Anda perlu menjelaskannya, maka itu tidak lagi lucu. " - Dave Cheney

Kunci untuk kejelasan maksimum adalah nama yang kami pilih untuk mengidentifikasi program. Kualitas apa yang melekat pada nama baik?

  • Nama yang baik ringkas . Itu tidak harus menjadi yang terpendek, tetapi tidak mengandung kelebihan. Ini memiliki rasio sinyal terhadap noise yang tinggi.
  • Nama yang baik adalah deskriptif . Ini menggambarkan penggunaan variabel atau konstan, bukan isinya. Nama yang bagus menjelaskan hasil fungsi atau perilaku metode, bukan implementasi. Tujuan paket, bukan isinya. Semakin akurat nama tersebut menggambarkan hal yang mengidentifikasi, semakin baik.
  • Nama yang baik bisa ditebak . Dengan satu nama, Anda harus memahami bagaimana objek akan digunakan. Nama-nama harus deskriptif, tetapi juga penting untuk mengikuti tradisi. Itulah yang dimaksud programmer Go ketika mereka mengatakan "idiomatik . "

Mari kita bahas secara lebih rinci masing-masing properti ini.

2.2. Panjang ID


Terkadang gaya Go dikritik karena nama variabel pendek. Seperti yang dikatakan Rob Pike, "Go programmer menginginkan pengidentifikasi dengan panjang yang benar ."

Andrew Gerrand menawarkan pengidentifikasi yang lebih lama untuk menunjukkan pentingnya.

"Semakin besar jarak antara pernyataan nama dan penggunaan suatu objek, semakin lama namanya" - Andrew Gerrand

Dengan demikian, beberapa rekomendasi dapat dibuat:

  • Nama variabel pendek baik jika jarak antara deklarasi dan penggunaan terakhir kecil.
  • Nama variabel panjang harus membenarkan diri mereka sendiri; semakin lama mereka, semakin penting mereka seharusnya. Judul verbose mengandung sedikit sinyal sehubungan dengan bobotnya pada halaman.
  • Jangan sertakan nama tipe dalam nama variabel.
  • Nama konstan harus menggambarkan nilai internal, bukan bagaimana nilai tersebut digunakan.
  • Lebih suka variabel huruf tunggal untuk loop dan cabang, kata-kata terpisah untuk parameter dan nilai kembali, beberapa kata untuk fungsi dan deklarasi pada tingkat paket.
  • Lebih suka kata-kata tunggal untuk metode, antarmuka, dan paket.
  • Ingat bahwa nama paket adalah bagian dari nama yang digunakan pemanggil untuk referensi.

Pertimbangkan sebuah contoh.

type Person struct { Name string Age int } // AverageAge returns the average age of people. func AverageAge(people []Person) int { if len(people) == 0 { return 0 } var count, sum int for _, p := range people { sum += p.Age count += 1 } return sum / count } 

Di baris kesepuluh, variabel rentang p dideklarasikan, dan dipanggil hanya sekali dari baris berikutnya. Artinya, variabel tinggal di halaman untuk waktu yang sangat singkat. Jika pembaca tertarik pada peran p dalam program ini, ia hanya perlu membaca dua baris.

Sebagai perbandingan, people dinyatakan dalam parameter fungsi dan tujuh baris langsung. Hal yang sama berlaku untuk sum dan count , sehingga mereka membenarkan nama panjang mereka. Pembaca perlu memindai lebih banyak kode untuk menemukannya: ini membenarkan nama-nama yang lebih terhormat.

Anda dapat memilih s untuk sum dan c (atau n ) untuk count , tetapi ini mengurangi pentingnya semua variabel dalam program ke tingkat yang sama. Anda dapat mengganti people dengan p , tetapi akan ada masalah, apa yang harus dipanggil variabel iterasi for ... range . Satu person akan terlihat aneh, karena variabel iterasi berumur pendek mendapatkan nama yang lebih panjang daripada beberapa nilai dari mana ia berasal.

Kiat . Pisahkan aliran fungsi dengan baris kosong, karena baris kosong di antara paragraf memecah aliran teks. Di AverageAge , kami memiliki tiga operasi berturut-turut. Pertama, memeriksa pembagian dengan nol, kemudian kesimpulan dari total usia dan jumlah orang, dan yang terakhir - perhitungan usia rata-rata.

2.2.1. Yang utama adalah konteks


Penting untuk dipahami bahwa sebagian besar kiat penamaan bersifat spesifik konteks. Saya suka mengatakan bahwa ini adalah prinsip, bukan aturan.

Apa perbedaan antara i dan index ? Misalnya, Anda tidak dapat mengatakan dengan tegas bahwa kode tersebut

 for index := 0; index < len(s); index++ { // } 

secara fundamental lebih mudah dibaca daripada

 for i := 0; i < len(s); i++ { // } 

Saya percaya bahwa pilihan kedua tidak lebih buruk, karena dalam hal ini wilayah i atau index dibatasi oleh badan for , dan verbositas tambahan hanya menambah sedikit pemahaman program.

Tapi yang mana dari fungsi-fungsi ini yang lebih mudah dibaca?

 func (s *SNMP) Fetch(oid []int, index int) (int, error) 

atau

 func (s *SNMP) Fetch(o []int, i int) (int, error) 

Dalam contoh ini, oid adalah singkatan dari SNMP Object ID, dan singkatan tambahan untuk o forces ketika membaca kode untuk beralih dari notasi yang terdokumentasi ke notasi yang lebih pendek dalam kode. Demikian pula, menyusut index ke i membuatnya lebih sulit untuk dipahami, karena dalam pesan SNMP, sub nilai setiap OID disebut indeks.

Kiat . Jangan gabungkan parameter formal panjang dan pendek dalam satu iklan.

2.3. Jangan beri nama variabel berdasarkan tipe


Anda tidak memanggil hewan peliharaan Anda "anjing" dan "kucing", kan? Untuk alasan yang sama, Anda tidak harus memasukkan nama tipe dalam nama variabel. Itu harus menggambarkan konten, bukan tipenya. Pertimbangkan sebuah contoh:

 var usersMap map[string]*User 

Apa gunanya pengumuman ini? Kami melihat bahwa ini adalah peta, dan ini ada hubungannya dengan *User Jenis *User : ini mungkin bagus. Tetapi usersMap benar-benar sebuah peta, dan Go sebagai bahasa yang diketik secara statis tidak akan memungkinkan secara tidak sengaja menggunakan nama seperti itu di mana variabel skalar diperlukan, sehingga akhiran Map banyak.

Pertimbangkan situasi di mana variabel lain ditambahkan:

 var ( companiesMap map[string]*Company productsMap map[string]*Products ) 

Sekarang kita memiliki tiga variabel tipe peta: usersMap , usersMap dan productsMap , dan semua garis dipetakan ke tipe yang berbeda. Kami tahu ini adalah peta, dan kami juga tahu bahwa kompiler akan membuat kesalahan jika kami mencoba menggunakan companiesMap mana kode mengharapkan map[string]*User . Dalam situasi ini, jelas bahwa akhiran Map tidak meningkatkan kejelasan kode, ini hanya karakter tambahan.

Saya sarankan menghindari sufiks yang menyerupai jenis variabel.

Kiat . Jika nama users tidak menggambarkan esensi dengan cukup jelas, maka usersMap juga.

Tip ini juga berlaku untuk parameter fungsi. Sebagai contoh:

 type Config struct { // } func WriteConfig(w io.Writer, config *Config) 

Nama config untuk parameter *Config berlebihan. Kita sudah tahu bahwa ini *Config , segera ditulis di sebelahnya.

Dalam hal ini, pertimbangkan conf atau c jika umur variabel cukup pendek.

Jika pada suatu titik di daerah kami ada lebih dari satu *Config , maka nama conf1 dan conf2 kurang bermakna daripada yang original dan updated , karena yang terakhir lebih sulit untuk digabungkan.

Catatan Jangan biarkan nama paket mencuri nama variabel yang bagus.

Nama pengenal yang diimpor berisi nama paket. Misalnya, tipe context paket context akan disebut context.Context . Ini membuat tidak mungkin untuk menggunakan variabel atau jenis context dalam paket Anda.

 func WriteLog(context context.Context, message string) 

Ini tidak akan dikompilasi. Itu sebabnya ketika mendeklarasikan context.Context Jenis context.Context secara lokal, misalnya, nama seperti ctx secara tradisional digunakan.

 func WriteLog(ctx context.Context, message string) 

2.4. Gunakan gaya penamaan tunggal


Properti lain dari nama baik adalah bahwa itu harus dapat diprediksi. Pembaca harus segera memahaminya. Jika ini adalah nama yang umum , maka pembaca memiliki hak untuk berasumsi bahwa itu tidak mengubah artinya dari waktu sebelumnya.

Misalnya, jika kode berjalan di sekitar deskriptor database, setiap kali parameter ditampilkan, itu harus memiliki nama yang sama. Daripada semua jenis kombinasi seperti d *sql.DB , dbase *sql.DB , DB *sql.DB dan database *sql.DB lebih baik menggunakan satu hal:

 db *sql.DB 

Lebih mudah untuk memahami kodenya. Jika Anda melihat db , maka Anda tahu bahwa itu *sql.DB dan dideklarasikan secara lokal atau disediakan oleh pemanggil.

Saran serupa mengenai penerima suatu metode; gunakan nama penerima yang sama untuk setiap metode jenis ini. Ini akan memudahkan pembaca untuk memahami penggunaan penerima di antara berbagai metode jenis ini.

Catatan Go Recipient Short Name Agreement bertentangan dengan rekomendasi yang disuarakan sebelumnya. Ini adalah salah satu kasus di mana pilihan yang dibuat pada tahap awal menjadi gaya standar, seperti menggunakan CamelCase alih-alih snake_case .

Kiat . Gaya Go menunjuk ke nama huruf tunggal atau singkatan untuk penerima yang berasal dari tipenya. Mungkin ternyata nama penerima terkadang bertentangan dengan nama parameter dalam metode. Dalam hal ini, disarankan untuk membuat nama parameter sedikit lebih lama dan jangan lupa untuk menggunakannya secara berurutan.

Akhirnya, beberapa variabel satu huruf secara tradisional dikaitkan dengan loop dan penghitungan. Sebagai contoh, i , j dan k biasanya variabel induktif dalam for loop, n biasanya dikaitkan dengan penghitung kontra atau akumulatif, v adalah singkatan khas untuk nilai dalam fungsi penyandian, k biasanya digunakan untuk kunci peta, dan s sering digunakan sebagai singkatan untuk parameter string tipe .

Seperti contoh db atas, programmer berharap i menjadi variabel induktif. Jika mereka melihatnya dalam kode, mereka berharap akan melihat loop segera.

Kiat . Jika Anda memiliki begitu banyak loop bersarang sehingga Anda telah kehabisan variabel i , j dan k , maka Anda mungkin ingin memecah fungsi menjadi unit yang lebih kecil.

2.5. Gunakan gaya deklarasi tunggal


Go memiliki setidaknya enam cara berbeda untuk mendeklarasikan variabel.

  •  var x int = 1 
  •  var x = 1 
  •  var x int; x = 1 
  •  var x = int(1) 
  •  x := 1 

Saya yakin saya belum mengingat semuanya. Pengembang Go mungkin menganggap ini kesalahan, tetapi sudah terlambat untuk mengubah apa pun. Dengan pilihan ini, bagaimana memastikan gaya yang seragam?

Saya ingin mengusulkan gaya mendeklarasikan variabel yang saya sendiri coba gunakan sedapat mungkin.

  • Saat mendeklarasikan variabel tanpa inisialisasi, gunakan var .

     var players int // 0 var things []Thing // an empty slice of Things var thing Thing // empty Thing struct json.Unmarshall(reader, &thing) 

    var bertindak sebagai petunjuk bahwa variabel ini sengaja dinyatakan sebagai nilai nol dari tipe yang ditentukan. Ini konsisten dengan persyaratan untuk mendeklarasikan variabel pada tingkat paket dengan var bertentangan dengan sintaks deklarasi pendek, meskipun saya akan berpendapat kemudian bahwa variabel tingkat paket tidak boleh digunakan sama sekali.
  • Ketika mendeklarasikan dengan inisialisasi, gunakan := . Ini menjelaskan kepada pembaca bahwa variabel di sebelah kiri := sengaja diinisialisasi.

    Untuk menjelaskan alasannya, mari kita lihat contoh sebelumnya, tetapi kali ini kami khusus menginisialisasi setiap variabel:

     var players int = 0 var things []Thing = nil var thing *Thing = new(Thing) json.Unmarshall(reader, thing) 

Karena Go tidak memiliki konversi otomatis dari satu jenis ke yang lain, dalam contoh pertama dan ketiga jenis di sisi kiri operator penugasan harus identik dengan jenis di sisi kanan. Compiler dapat menyimpulkan jenis variabel yang dideklarasikan dari jenis di sebelah kanan, jadi contohnya dapat ditulis lebih ringkas:

 var players = 0 var things []Thing = nil var thing = new(Thing) json.Unmarshall(reader, thing) 

Di sini, players eksplisit diinisialisasi ke 0 , yang berlebihan, karena nilai awal players adalah nol dalam hal apa pun. Oleh karena itu, lebih baik untuk menjelaskan bahwa kami ingin menggunakan nilai nol:

 var players int 

Bagaimana dengan operator kedua? Kami tidak dapat menentukan jenis dan menulis

 var things = nil 

Karena nil tidak nil tipe . Sebaliknya, kami punya pilihan: atau kami menggunakan nilai nol untuk mengiris ...

 var things []Thing 

... atau membuat irisan dengan nol elemen?

 var things = make([]Thing, 0) 

Dalam kasus kedua, nilai untuk slice tidak nol, dan kami menjelaskan kepada pembaca menggunakan bentuk pendek pernyataan:

 things := make([]Thing, 0) 

Ini memberi tahu pembaca bahwa kami memutuskan untuk menginisialisasi things secara eksplisit.

Jadi kita sampai pada deklarasi ketiga:

 var thing = new(Thing) 

Di sini, baik inisialisasi eksplisit dari variabel dan pengenalan kata kunci "unik" new , yang tidak disukai sebagian programmer. Menggunakan sintaks pendek yang disarankan

 thing := new(Thing) 

Ini memperjelas thing secara eksplisit diinisialisasi ke hasil new(Thing) , tetapi masih menyisakan yang new atipikal. Masalahnya bisa diselesaikan dengan menggunakan literal:

 thing := &Thing{} 

Yang mirip dengan yang new(Thing) , dan duplikasi semacam itu mengecewakan beberapa programmer Go. Namun, ini berarti bahwa kita secara eksplisit menginisialisasi thing dengan pointer ke Thing{} dan nilai Thing nol.

Tetapi lebih baik memperhitungkan fakta bahwa thing dinyatakan dengan nilai nol, dan gunakan alamat operator untuk meneruskan alamat thing di json.Unmarshall :

 var thing Thing json.Unmarshall(reader, &thing) 

Catatan Tentu saja, ada pengecualian untuk aturan apa pun. Misalnya, kadang-kadang dua variabel terkait erat, sehingga akan aneh untuk menulis

 var min int max := 1000 

Deklarasi yang lebih mudah dibaca:

 min, max := 0, 1000 

Untuk meringkas:

  • Saat mendeklarasikan variabel tanpa inisialisasi, gunakan sintaks var .
  • Ketika mendeklarasikan dan secara eksplisit menginisialisasi variabel, gunakan := .

Kiat . Secara eksplisit tunjukkan hal-hal kompleks.

 var length uint32 = 0x80 

Di sini length dapat digunakan dengan pustaka, yang memerlukan jenis numerik tertentu, dan opsi ini lebih jelas menunjukkan bahwa panjang jenis secara khusus dipilih sebagai uint32 daripada dalam deklarasi pendek:

 length := uint32(0x80) 

Dalam contoh pertama, saya sengaja melanggar aturan saya dengan menggunakan deklarasi var dengan inisialisasi eksplisit. Penyimpangan dari standar membuat pembaca mengerti bahwa sesuatu yang tidak biasa sedang terjadi.

2.6. Bekerja untuk tim


Saya telah mengatakan bahwa esensi pengembangan perangkat lunak adalah penciptaan kode yang dapat dibaca dan didukung. Sebagian besar karir Anda mungkin akan bekerja pada proyek bersama. Saran saya dalam situasi ini: ikuti gaya yang diadopsi dalam tim.

Mengubah gaya di tengah file itu menjengkelkan. Konsistensi adalah penting, meskipun merugikan preferensi pribadi. Aturan praktis saya adalah: jika kode tersebut cocok dengan gofmt , maka masalahnya biasanya tidak layak untuk dibahas.

Kiat . Jika Anda ingin mengganti nama di seluruh basis kode, jangan campur ini dengan perubahan lain. Jika seseorang menggunakan git bisect, ia tidak akan suka mengarungi ribuan nama untuk menemukan kode lain yang dimodifikasi.

3. Komentar


Sebelum kita beralih ke poin yang lebih penting, saya ingin mengambil beberapa menit untuk berkomentar.

“Kode yang baik memiliki banyak komentar, dan kode yang buruk membutuhkan banyak komentar.” - Dave Thomas dan Andrew Hunt, Pragmatic Programmer

Komentar sangat penting untuk keterbacaan program. Setiap komentar harus melakukan satu - dan hanya satu - dari tiga hal:

  1. Jelaskan apa yang dilakukan kode.
  2. Jelaskan bagaimana dia melakukannya.
  3. Jelaskan mengapa .

Bentuk pertama sangat ideal untuk mengomentari karakter publik:

 // Open     . //           . 

Yang kedua sangat ideal untuk komentar di dalam suatu metode:

 //     var results []chan error for _, dep := range a.Deps { results = append(results, execute(seen, dep)) } 

Bentuk ketiga ("mengapa") unik karena tidak menggantikan atau mengganti dua yang pertama. Komentar semacam itu menjelaskan faktor-faktor eksternal yang menyebabkan penulisan kode dalam bentuk saat ini. Seringkali tanpa konteks ini, sulit untuk memahami mengapa kode ditulis dengan cara ini.

 return &v2.Cluster_CommonLbConfig{ //  HealthyPanicThreshold HealthyPanicThreshold: &envoy_type.Percent{ Value: 0, }, } 

Dalam contoh ini, mungkin tidak segera jelas apa yang terjadi ketika HealthyPanicThreshold diatur ke nol persen. Komentar ini dimaksudkan untuk mengklarifikasi bahwa nilai 0 menonaktifkan ambang panik.

3.1. Komentar dalam variabel dan konstanta harus menggambarkan isinya, bukan tujuan


Saya katakan sebelumnya bahwa nama variabel atau konstanta harus menggambarkan tujuannya. Tetapi komentar pada variabel atau konstanta harus menggambarkan konten dengan tepat, bukan tujuannya .

 const randomNumber = 6 //     

Dalam contoh ini, komentar menjelaskan mengapa randomNumber ke 6 dan dari mana asalnya. Komentar tidak menjelaskan di mana randomNumber akan digunakan. Berikut ini beberapa contoh lainnya:

 const ( StatusContinue = 100 // RFC 7231, 6.2.1 StatusSwitchingProtocols = 101 // RFC 7231, 6.2.2 StatusProcessing = 102 // RFC 2518, 10.1 StatusOK = 200 // RFC 7231, 6.3.1 

Dalam konteks HTTP, angka 100 dikenal sebagai StatusContinue , sebagaimana didefinisikan dalam RFC 7231, bagian 6.2.1.

Kiat . Untuk variabel tanpa nilai awal, komentar harus menjelaskan siapa yang bertanggung jawab untuk menginisialisasi variabel ini.

 // sizeCalculationDisabled ,   //     . . dowidth. var sizeCalculationDisabled bool 

Di sini komentar memberi tahu pembaca bahwa fungsi dowidth bertanggung jawab untuk mempertahankan status sizeCalculationDisabled .

Kiat . Sembunyikan di depan mata. Ini saran dari Kate Gregory . Terkadang nama terbaik untuk variabel disembunyikan di komentar.

 //   SQL var registry = make(map[string]*sql.Driver) 

Sebuah komentar ditambahkan oleh penulis karena nama registry tidak cukup menjelaskan tujuannya - ini adalah registri, tetapi apa itu registri?

Jika Anda mengganti nama variabel menjadi sqlDrivers, menjadi jelas bahwa itu berisi driver SQL.

 var sqlDrivers = make(map[string]*sql.Driver) 

Sekarang komentar telah menjadi berlebihan dan dapat dihapus.

3.2. Selalu mendokumentasikan karakter yang tersedia untuk umum


Dokumentasi untuk paket Anda dibuat oleh godoc, jadi Anda harus menambahkan komentar untuk setiap karakter publik yang dinyatakan dalam paket: variabel, konstanta, fungsi, dan metode.

Berikut adalah dua panduan dari Panduan Gaya Google:

  • Setiap fungsi publik yang tidak jelas dan ringkas harus dikomentari.
  • Setiap fungsi di perpustakaan harus dikomentari, terlepas dari panjang atau kompleksitasnya.


 package ioutil // ReadAll   r      (EOF)   // ..    err == nil, not err == EOF. //  ReadAll     ,     //  . func ReadAll(r io.Reader) ([]byte, error) 

Ada satu pengecualian untuk aturan ini: Anda tidak perlu mendokumentasikan metode yang mengimplementasikan antarmuka. Secara khusus, jangan lakukan ini:

 // Read   io.Reader func (r *FileReader) Read(buf []byte) (int, error) 

Komentar ini tidak berarti apa-apa. Dia tidak mengatakan apa yang dilakukan metodenya: lebih buruk, dia mengirim suatu tempat untuk mencari dokumentasi. Dalam situasi ini, saya mengusulkan untuk sepenuhnya menghapus komentar.

Ini adalah contoh dari paket io .

 // LimitReader  Reader,    r, //    EOF  n . //   *LimitedReader. func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} } // LimitedReader   R,     //   N .   Read  N  //    . // Read  EOF,  N <= 0    R  EOF. type LimitedReader struct { R Reader // underlying reader N int64 // max bytes remaining } func (l *LimitedReader) Read(p []byte) (n int, err error) { if lN <= 0 { return 0, EOF } if int64(len(p)) > lN { p = p[0:lN] } n, err = lRRead(p) lN -= int64(n) return } 

Perhatikan bahwa deklarasi LimitedReader segera didahului oleh fungsi yang menggunakannya, dan deklarasi LimitedReader.Read mengikuti deklarasi LimitedReader itu sendiri. Meskipun LimitedReader.Read sendiri tidak didokumentasikan, dapat dipahami bahwa ini adalah implementasi dari io.Reader .

Kiat . Sebelum menulis fungsi, tulis komentar yang menjelaskannya. Jika Anda merasa sulit untuk menulis komentar, maka ini pertanda bahwa kode yang akan Anda tulis akan sulit dimengerti.

3.2.1. Jangan mengomentari kode yang buruk, tulis ulang


“Jangan Mengomentari Kode Buruk - Tulis Ulang Itu” - Brian Kernighan

Tidak cukup untuk mengindikasikan kesulitan dalam fragmen kode di komentar. Jika Anda menemukan salah satu komentar ini, Anda harus memulai tiket dengan pengingat refactoring. Anda dapat hidup dengan hutang teknis selama jumlahnya diketahui.

Di perpustakaan standar, biasanya meninggalkan komentar dalam gaya TODO dengan nama pengguna yang memperhatikan masalah tersebut.

 // TODO(dfc)  O(N^2),     . 

Ini bukan kewajiban untuk memperbaiki masalah, tetapi pengguna yang ditunjukkan mungkin orang terbaik untuk mengajukan pertanyaan. Proyek lain menemani TODO dengan tanggal atau nomor tiket.

3.2.2. Alih-alih berkomentar kode, refactor saja


“Kode yang baik adalah dokumentasi terbaik. Ketika Anda akan menambahkan komentar, tanyakan pada diri sendiri pertanyaan: "Bagaimana cara meningkatkan kode sehingga komentar ini tidak diperlukan?" Refactor dan tinggalkan komentar untuk membuatnya lebih jelas. " - Steve McConnell

Fungsi seharusnya hanya melakukan satu tugas. Jika Anda ingin menulis komentar karena beberapa fragmen tidak terkait dengan sisa fungsi, maka pertimbangkan untuk mengekstraksinya menjadi fungsi yang terpisah.

Fitur yang lebih kecil tidak hanya lebih jelas, tetapi lebih mudah untuk diuji secara terpisah dari satu sama lain. Ketika Anda mengisolasi kode menjadi fungsi yang terpisah, namanya dapat menggantikan komentar.

4. Struktur paket


“Tulis kode sederhana: modul yang tidak menunjukkan apa-apa berlebihan ke modul lain dan yang tidak bergantung pada implementasi modul lain” - Dave Thomas

Setiap paket pada dasarnya adalah program Go kecil yang terpisah. Sama seperti implementasi fungsi atau metode tidak masalah bagi pemanggil, implementasi fungsi, metode dan tipe yang membentuk API publik dari paket Anda tidak masalah.

Paket Go yang baik berusaha untuk konektivitas minimal dengan paket lain di tingkat kode sumber sehingga seiring pertumbuhan proyek, perubahan dalam satu paket tidak mengalir di seluruh basis kode. Situasi seperti ini sangat menghambat programmer yang bekerja pada basis kode ini.

Di bagian ini, kita akan berbicara tentang desain paket, termasuk nama dan tip untuk metode dan fungsi penulisan.

4.1. Paket yang baik dimulai dengan nama yang bagus


Paket Go yang baik dimulai dengan nama yang berkualitas. Anggap saja sebagai presentasi singkat yang hanya terbatas pada satu kata.

Seperti nama variabel di bagian sebelumnya, nama paket sangat penting. Tidak perlu memikirkan tipe data dalam paket ini, lebih baik bertanya: "Layanan apa yang disediakan paket ini?" Biasanya jawabannya bukan "Paket ini menyediakan tipe X", tetapi "Paket ini memungkinkan Anda untuk terhubung melalui HTTP."

Kiat . Pilih nama paket berdasarkan fungsinya, bukan isinya.

4.1.1. Nama paket yang bagus harus unik


Setiap paket memiliki nama unik dalam proyek tersebut. Tidak ada kesulitan jika Anda mengikuti saran memberi nama untuk tujuan paket. Jika ternyata kedua paket memiliki nama yang sama, kemungkinan besar:

  1. .
  2. . , .

4.2. base , common util


Alasan umum untuk nama buruk adalah paket layanan yang disebut , di mana seiring waktu berbagai penolong dan kode layanan menumpuk. Karena sulit untuk menemukan nama yang unik di sana. Hal ini sering mengarah pada fakta bahwa nama paket menjadi atau diperoleh dari fakta bahwa itu termasuk : utilitas.

Nama-nama suka utilsatau helpersbiasanya ditemukan di proyek-proyek besar, di mana hierarki paket yang dalam di-root, dan fungsi-fungsi tambahan dibagikan. Jika Anda mengekstrak beberapa fungsi ke dalam paket baru, impor akan rusak. Dalam hal ini, nama paket tidak mencerminkan tujuan paket, tetapi hanya fakta bahwa fungsi impor gagal karena organisasi proyek yang tidak tepat.

Dalam situasi seperti itu, saya merekomendasikan untuk menganalisis dari mana paket-paket itu berasal.utils helpers, dan, jika mungkin, pindahkan fungsi yang sesuai ke paket panggilan. Bahkan jika ini menyiratkan duplikasi beberapa kode tambahan, itu lebih baik daripada memperkenalkan ketergantungan impor antara dua paket.

“Duplikasi [sedikit] jauh lebih murah daripada abstraksi yang salah” - Sandy Mets

Jika fungsi utilitas digunakan di banyak tempat, alih-alih satu paket monolitik dengan fungsi utilitas, lebih baik untuk membuat beberapa paket, yang masing-masing berfokus pada satu aspek.

Kiat . Gunakan jamak untuk paket layanan. Misalnya, stringsuntuk utilitas pemrosesan string.

Paket dengan nama-nama seperti baseatau commonsering dijumpai ketika fungsionalitas umum tertentu dari dua atau lebih implementasi atau tipe umum untuk klien dan server digabungkan ke dalam paket terpisah. Saya percaya bahwa dalam kasus seperti itu perlu untuk mengurangi jumlah paket dengan menggabungkan klien, server dan kode umum dalam satu paket dengan nama yang sesuai dengan fungsinya.

Misalnya, untuk net/httptidak melakukan paket-paket individual clientdan server, sebaliknya, ada file client.godan server.godengan tipe data yang sesuai, serta transport.gountuk total transportasi.

Kiat . Penting untuk diingat bahwa nama pengenal termasuk nama paket.

  • Fungsi Getdari suatu paket net/httpmenjadi http.Gettautan dari paket lain.
  • Suatu jenis Readerdari suatu paket stringsditransformasikan menjadi ketika diimpor ke dalam paket lain strings.Reader.
  • Antarmuka Errordari paket netjelas terkait dengan kesalahan jaringan.

4.3. Kembalilah dengan cepat tanpa menyelam jauh


Karena Go tidak menggunakan pengecualian dalam aliran kontrol, tidak perlu menggali jauh ke dalam kode untuk menyediakan struktur tingkat atas trydan blok catch. Alih-alih hierarki multi-level, kode Go turun layar saat fungsi berlangsung. Teman saya Matt Ryer menyebut latihan ini "garis pandang . "

Ini dicapai dengan menggunakan operator batas : blok bersyarat dengan prasyarat pada input ke fungsi. Ini adalah contoh dari paket bytes:

 func (b *Buffer) UnreadRune() error { if b.lastRead <= opInvalid { return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune") } if b.off >= int(b.lastRead) { b.off -= int(b.lastRead) } b.lastRead = opInvalid return nil } 

Saat memasuki fungsi UnreadRune, keadaan diperiksa b.lastReaddan jika operasi sebelumnya tidak ReadRune, maka kesalahan segera dikembalikan. Sisa fungsi bekerja berdasarkan apa yang b.lastReadlebih besar dari opInvalid.

Bandingkan dengan fungsi yang sama, tetapi tanpa operator batas:

 func (b *Buffer) UnreadRune() error { if b.lastRead > opInvalid { if b.off >= int(b.lastRead) { b.off -= int(b.lastRead) } b.lastRead = opInvalid return nil } return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune") } 

Tubuh cabang yang lebih mungkin berhasil tertanam dalam kondisi pertama if, dan kondisi untuk keluar yang sukses return nilharus ditemukan dengan mencocokkan tanda kurung penutup dengan hati-hati . Baris terakhir dari fungsi sekarang mengembalikan kesalahan, dan Anda perlu melacak pelaksanaan fungsi ke braket pembuka yang sesuai untuk mengetahui bagaimana menuju ke titik ini.

Opsi ini lebih sulit dibaca, yang menurunkan kualitas pemrograman dan dukungan kode, jadi Go lebih suka menggunakan operator batas dan mengembalikan kesalahan pada tahap awal.

4.4. Jadikan nilai nol bermanfaat


Setiap deklarasi variabel, dengan asumsi tidak adanya penginisialisasi eksplisit, akan secara otomatis diinisialisasi dengan nilai yang sesuai dengan isi memori zeroed, yaitu nol . Jenis nilai ditentukan oleh salah satu opsi: untuk tipe numerik - nol, untuk tipe pointer - nihil, sama untuk irisan, peta, dan saluran.

Kemampuan untuk selalu menetapkan nilai default yang diketahui adalah penting untuk keamanan dan kebenaran program Anda dan dapat membuat program Go Anda lebih mudah dan lebih kompak. Inilah yang dipikirkan oleh programmer Go ketika mereka berkata, "Berikan struktur nilai nol yang bermanfaat."

Pertimbangkan jenis sync.Mutexyang berisi dua bidang bilangan bulat yang mewakili keadaan internal mutex. Bidang-bidang ini secara otomatis null dalam deklarasi apa pun.sync.Mutex. Fakta ini diperhitungkan dalam kode, sehingga jenis ini cocok untuk digunakan tanpa inisialisasi eksplisit.

 type MyInt struct { mu sync.Mutex val int } func main() { var i MyInt // i.mu is usable without explicit initialisation. i.mu.Lock() i.val++ i.mu.Unlock() } 

Contoh lain dari tipe dengan nilai nol yang berguna adalah bytes.Buffer. Anda dapat mendeklarasikan dan mulai menulis padanya tanpa inisialisasi eksplisit.

 func main() { var b bytes.Buffer b.WriteString("Hello, world!\n") io.Copy(os.Stdout, &b) } 

Nilai nol dari struktur ini berarti lenkeduanya capsama 0, dan y array, penunjuk ke memori dengan isi array slice cadangan, nilainya nil. Ini berarti bahwa Anda tidak perlu memotong secara eksplisit, Anda cukup mendeklarasikannya.

 func main() { // s := make([]string, 0) // s := []string{} var s []string s = append(s, "Hello") s = append(s, "world") fmt.Println(strings.Join(s, " ")) } 

Catatan . var s []stringmirip dengan dua baris komentar di atas, tetapi tidak identik dengan mereka. Ada perbedaan antara nilai irisan nil dan nilai irisan panjang nol. Kode berikut akan mencetak false.

 func main() { var s1 = []string{} var s2 []string fmt.Println(reflect.DeepEqual(s1, s2)) } 

Properti berguna, meskipun tidak terduga, dari variabel pointer tidak diinisialisasi - pointer nil - adalah kemampuan untuk memanggil metode pada jenis yang nihil. Ini dapat digunakan untuk dengan mudah memberikan nilai default.

 type Config struct { path string } func (c *Config) Path() string { if c == nil { return "/usr/home" } return c.path } func main() { var c1 *Config var c2 = &Config{ path: "/export", } fmt.Println(c1.Path(), c2.Path()) } 

4.5. Hindari status tingkat paket


Kunci untuk menulis program yang mudah didukung yang terhubung dengan lemah adalah bahwa mengubah satu paket harus memiliki probabilitas rendah mempengaruhi paket lain yang tidak secara langsung tergantung pada yang pertama.

Ada dua cara hebat untuk mencapai konektivitas lemah di Go:

  1. Gunakan antarmuka untuk menggambarkan perilaku yang diperlukan oleh fungsi atau metode.
  2. Hindari status global.

Di Go, kita bisa mendeklarasikan variabel dalam lingkup fungsi atau metode, serta dalam lingkup paket. Ketika sebuah variabel tersedia untuk umum, dengan pengenal dengan huruf kapital, maka cakupannya sebenarnya bersifat global untuk seluruh program: setiap paket kapan saja melihat jenis dan isi dari variabel ini.

Keadaan global yang dapat berubah memberikan hubungan yang erat antara bagian-bagian independen dari program, karena variabel global menjadi parameter yang tidak terlihat untuk setiap fungsi dalam program! Fungsi apa pun yang bergantung pada variabel global dapat dilanggar ketika jenis variabel ini berubah. Fungsi apa pun yang bergantung pada keadaan variabel global dapat dilanggar jika bagian lain dari program mengubah variabel ini.

Cara mengurangi konektivitas yang diciptakan variabel global:

  1. Pindahkan variabel terkait sebagai bidang ke struktur yang membutuhkannya.
  2. Gunakan antarmuka untuk mengurangi koneksi antara perilaku dan penerapan perilaku ini.

5. Struktur proyek


Mari kita bicara tentang bagaimana paket digabungkan menjadi sebuah proyek. Ini biasanya repositori Git tunggal.

Seperti paket, setiap proyek harus memiliki tujuan yang jelas. Jika itu adalah perpustakaan, ia harus melakukan satu hal, misalnya, parsing XML atau penjurnalan. Anda seharusnya tidak menggabungkan beberapa tujuan dalam satu proyek, ini akan membantu menghindari perpustakaan yang menakutkan common.

Kiat . Dalam pengalaman saya, repositori commonpada akhirnya terkait erat dengan konsumen terbesar, dan ini membuat sulit untuk melakukan koreksi ke versi sebelumnya (perbaikan port-belakang) tanpa memperbarui kedua commondan konsumen pada tahap pemblokiran, yang mengarah ke banyak perubahan yang tidak terkait, ditambah mereka pecah sepanjang jalan API

Jika Anda memiliki aplikasi (aplikasi web, pengontrol Kubernetes, dll.), Proyek mungkin memiliki satu atau lebih paket utama. Sebagai contoh, di pengontrol Kubernetes saya ada satu paket cmd/contouryang berfungsi sebagai server yang digunakan di cluster Kubernetes dan sebagai klien debug.

5.1. Lebih sedikit paket tetapi lebih besar


Dalam ulasan kode, saya perhatikan salah satu kesalahan khas programmer yang beralih ke Go dari bahasa lain: mereka cenderung menyalahgunakan paket.

Go tidak menyediakan sistem yang rumit dari visibility: bahasa tidak cukup akses pengubah seperti dalam Java ( public, protected, privatedan implisit default). Tidak ada analog dari kelas ramah dari C ++.

Di Go, kami hanya memiliki dua pengubah akses: ini adalah pengidentifikasi publik dan pribadi, yang ditunjukkan oleh huruf pertama dari pengenal (huruf besar / kecil). Jika pengenal bersifat publik, namanya dimulai dengan huruf besar, dapat dirujuk oleh paket Go lainnya.

Catatan . Anda dapat mendengar kata "diekspor" atau "tidak diekspor" sebagai sinonim untuk publik dan pribadi.

Mengingat fitur kontrol akses yang terbatas, metode apa yang dapat digunakan untuk menghindari hierarki paket yang terlalu rumit?

Kiat . Di setiap paket, selain cmd/dan internal/harus ada kode sumber.

Saya telah berulang kali mengatakan bahwa lebih baik memilih paket yang lebih sedikit. Posisi default Anda seharusnya tidak membuat paket baru. Ini menyebabkan terlalu banyak tipe menjadi publik, menciptakan cakupan API yang luas dan kecil. Di bawah ini kami mempertimbangkan tesis ini secara lebih rinci.

Kiat . Datang dari Jawa?

Jika Anda berasal dari dunia Java atau C #, maka ingat aturan tak terucapkan: paket Java setara dengan file sumber tunggal .go. Paket Go setara dengan seluruh modul Maven atau .NET assembly.

5.1.1. Menyortir kode berdasarkan file menggunakan instruksi impor


Jika Anda mengatur paket berdasarkan layanan, haruskah Anda melakukan hal yang sama untuk file dalam paket? Bagaimana cara mengetahui kapan harus membagi satu file .gomenjadi beberapa file ? Bagaimana Anda tahu jika Anda telah melangkah terlalu jauh dan perlu memikirkan penggabungan file?

Berikut rekomendasi yang saya gunakan:

  • Mulai setiap paket dengan satu file .go. Beri nama file ini sama dengan direktori. Misalnya, paket httpharus dalam file http.godi direktori http.
  • Saat paket bertambah, Anda dapat membagi berbagai fungsi menjadi beberapa file. Sebagai contoh, file tersebut messages.goakan berisi tipe Requestdan Response, client.gotipe Clientfile server.go, tipe server file .
  • , . , .
  • . , messages.go HTTP- , http.go , client.go server.go — HTTP .

. .

. Go . ( — Go). .

5.1.2. Lebih suka tes internal daripada eksternal


Alat ini gomendukung paket testingdi dua tempat. Jika Anda memiliki paket http2, Anda dapat menulis file http2_test.godan menggunakan deklarasi paket http2. Mengkompilasi kode http2_test.go, seperti itu adalah bagian dari paket http2. Dalam pidato sehari-hari, tes semacam itu disebut internal.

Alat ini gojuga mendukung deklarasi paket khusus yang diakhiri dengan tes , yaitu http_test. Ini memungkinkan file tes untuk hidup dalam paket yang sama dengan kode, tetapi ketika tes tersebut dikompilasi, mereka bukan bagian dari kode paket Anda, tetapi hidup dalam paket mereka sendiri. Ini memungkinkan Anda untuk menulis tes seolah-olah paket lain memohon kode Anda. Tes semacam ini disebut eksternal.

Saya sarankan menggunakan tes internal untuk tes unit unit. Ini memungkinkan Anda menguji setiap fungsi atau metode secara langsung, menghindari birokrasi pengujian eksternal.

Tetapi perlu untuk menempatkan contoh fungsi uji ( Example) dalam file uji eksternal . Ini memastikan bahwa ketika dilihat di godoc, contoh-contoh akan menerima awalan paket yang sesuai dan dapat dengan mudah disalin.

. , .

, , Go go . , net/http net .

.go , , .

5.1.3. , API


Jika proyek Anda memiliki beberapa paket, Anda mungkin menemukan fungsi yang diekspor yang dimaksudkan untuk digunakan oleh paket lain, tetapi tidak untuk API publik. Dalam situasi seperti itu, alat ini gomengenali nama folder khusus internal/yang dapat digunakan untuk menempatkan kode yang terbuka untuk proyek Anda, tetapi tertutup bagi orang lain.

Untuk membuat paket seperti itu, letakkan di direktori dengan nama internal/atau di subdirektori. Ketika tim gomelihat impor paket dengan path internal, itu memeriksa lokasi paket panggilan dalam direktori atau subdirektori internal/.

Sebagai contoh, sebuah paket .../a/b/c/internal/d/e/fhanya dapat mengimpor paket dari pohon direktori .../a/b/c, tetapi tidak sama sekali .../a/b/gatau repositori lainnya (lihatdokumentasi ).

5.2. Paket utama terkecil


Fungsi maindan paket mainharus memiliki fungsionalitas minimal, karena main.mainberfungsi seperti singleton: program hanya dapat memiliki satu fungsi main, termasuk tes.

Karena itu main.mainsingleton, ada banyak batasan pada objek yang disebut: mereka dipanggil hanya selama main.mainatau main.init, dan hanya sekali . Ini membuat tes menulis untuk kode sulit main.main. Dengan demikian, Anda perlu berusaha untuk mendapatkan sebanyak mungkin logika dari fungsi utama dan, idealnya, dari paket utama.

Kiat . func main()harus menganalisis flag, membuka koneksi ke database, logger, dll, dan kemudian mentransfer eksekusi ke objek tingkat tinggi.

6. struktur API


Saran desain terakhir untuk proyek yang saya anggap paling penting.

Semua kalimat sebelumnya, pada prinsipnya, tidak mengikat. Ini hanya rekomendasi berdasarkan pengalaman pribadi. Saya tidak mendorong rekomendasi ini terlalu banyak ke tinjauan kode.

API adalah masalah lain: Di sini, kesalahan dilakukan dengan lebih serius, karena segala sesuatu dapat diperbaiki tanpa merusak kompatibilitas: sebagian besar, ini hanya detail implementasi.

Ketika berbicara tentang API publik, ada baiknya mempertimbangkan struktur secara serius sejak awal, karena perubahan selanjutnya akan merusak bagi pengguna.

6.1. Desain API yang sulit disalahgunakan oleh desain


“API harus sederhana untuk penggunaan yang tepat dan sulit untuk salah” - Josh Bloch

Nasihat Josh Bloch mungkin yang paling berharga dalam artikel ini. Jika API sulit digunakan untuk hal-hal sederhana, maka setiap panggilan API lebih rumit dari yang diperlukan. Saat panggilan API rumit dan tidak terlihat, kemungkinan akan diabaikan.

6.1.1. Hati-hati dengan fungsi yang menerima banyak parameter dari jenis yang sama.


Contoh sederhana dari pandangan pertama yang sederhana, tetapi sulit untuk menggunakan API adalah ketika membutuhkan dua atau lebih parameter dari jenis yang sama. Bandingkan dua tanda tangan fungsi:

 func Max(a, b int) int func CopyFile(to, from string) error 

Apa perbedaan antara kedua fungsi ini? Jelas, satu mengembalikan maksimal dua angka, dan yang lainnya menyalin file. Tapi ini bukan intinya.

 Max(8, 10) // 10 Max(10, 8) // 10 

Max komutatif : urutan parameter tidak masalah. Maksimal delapan dan sepuluh adalah sepuluh, terlepas dari apakah delapan dan sepuluh atau sepuluh dan delapan dibandingkan.

Tetapi dalam kasus CopyFile, ini tidak demikian.

 CopyFile("/tmp/backup", "presentation.md") CopyFile("presentation.md", "/tmp/backup") 

Manakah dari operator ini yang akan membuat cadangan presentasi Anda, dan yang akan menimpanya dengan versi minggu lalu? Anda tidak dapat memberi tahu sampai Anda memeriksa dokumentasinya. Dalam proses review kode, tidak jelas apakah urutan argumennya benar atau tidak. Sekali lagi, lihat dokumentasi.

Salah satu solusi yang mungkin adalah dengan memperkenalkan tipe tambahan yang bertanggung jawab untuk panggilan yang benar CopyFile.

 type Source string func (src Source) CopyTo(dest string) error { return CopyFile(dest, string(src)) } func main() { var from Source = "presentation.md" from.CopyTo("/tmp/backup") } 

Itu CopyFileselalu disebut dengan benar di sini - ini dapat dinyatakan menggunakan unit test - dan dapat dilakukan secara pribadi, yang selanjutnya mengurangi kemungkinan kesalahan penggunaan.

Kiat . API dengan beberapa parameter dari jenis yang sama sulit digunakan dengan benar.

6.2. Mendesain API untuk Kasus Penggunaan Dasar


Beberapa tahun yang lalu, saya membuat presentasi tentang penggunaan opsi fungsional untuk membuat API lebih mudah secara default.

Inti dari presentasi adalah Anda harus mengembangkan API untuk use case utama. Dengan kata lain, API seharusnya tidak mengharuskan pengguna untuk memberikan parameter tambahan yang tidak menarik baginya.

6.2.1. Tidak disarankan menggunakan nil sebagai parameter


Saya mulai dengan mengatakan bahwa Anda tidak boleh memaksa pengguna untuk memberikan parameter API yang tidak menarik baginya. Ini berarti merancang API untuk use case utama (opsi default).

Ini adalah contoh dari paket net / http.

 package http // ListenAndServe listens on the TCP network address addr and then calls // Serve with handler to handle requests on incoming connections. // Accepted connections are configured to enable TCP keep-alives. // // The handler is typically nil, in which case the DefaultServeMux is used. // // ListenAndServe always returns a non-nil error. func ListenAndServe(addr string, handler Handler) error { 

ListenAndServemenerima dua parameter: alamat TCP untuk mendengarkan koneksi yang masuk dan http.Handleruntuk memproses permintaan HTTP yang masuk. Servememungkinkan parameter kedua menjadi nil. Dalam komentar, dicatat bahwa biasanya objek pemanggil memang akan lewat nil, menunjukkan keinginan untuk menggunakannya http.DefaultServeMuxsebagai parameter implisit.

Sekarang si penelepon Servememiliki dua cara untuk melakukan hal yang sama.

 http.ListenAndServe("0.0.0.0:8080", nil) http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux) 

Kedua opsi melakukan hal yang sama.

Aplikasi ini nilmenyebar seperti virus. Paket ini juga httpmemiliki pembantu http.Serve, sehingga Anda dapat membayangkan struktur fungsi ListenAndServe:

 func ListenAndServe(addr string, handler Handler) error { l, err := net.Listen("tcp", addr) if err != nil { return err } defer l.Close() return Serve(l, handler) } 

Karena ListenAndServememungkinkan pemanggil lulus niluntuk parameter kedua, itu http.Servejuga mendukung perilaku ini. Bahkan, itu dalam http.Servelogika yang diterapkan "jika handler sama nil, gunakan DefaultServeMux". Penerimaan niluntuk satu parameter dapat membuat penelepon berpikir bahwa itu dapat diteruskan niluntuk kedua parameter. Tapi begitulahServe

 http.Serve(nil, nil) 

menyebabkan kepanikan yang mengerikan.

Kiat . Jangan mencampur parameter dalam tanda tangan fungsi yang sama nildan tidak nil.

Penulis http.ListenAndServemencoba menyederhanakan kehidupan pengguna API untuk kasus default, tetapi keamanan terpengaruh.

Di hadapan, niltidak ada perbedaan dalam jumlah garis antara penggunaan eksplisit dan tidak langsung DefaultServeMux.

  const root = http.Dir("/htdocs") http.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", nil) 

dibandingkan dengan

  const root = http.Dir("/htdocs") http.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux) 

Apakah ada gunanya kebingungan untuk menjaga satu garis?

  const root = http.Dir("/htdocs") mux := http.NewServeMux() mux.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", mux) 

Kiat . Pikirkan dengan serius tentang berapa banyak waktu fungsi pembantu akan menyelamatkan programmer. Kejelasan lebih baik daripada singkatnya.

Kiat . Hindari API publik dengan parameter yang hanya diperlukan pengujian. Hindari mengekspor API dengan parameter yang nilainya hanya berbeda selama pengujian. Sebagai gantinya, ekspor fungsi pembungkus yang menyembunyikan transfer parameter tersebut, dan dalam pengujian menggunakan fungsi pembantu serupa yang memberikan nilai yang diperlukan untuk pengujian.

6.2.2. Gunakan argumen panjang variabel alih-alih [] T


Sangat sering, suatu fungsi atau metode mengambil sepotong nilai.

 func ShutdownVMs(ids []string) error 

Ini hanya contoh buatan, tetapi ini sangat umum. Masalahnya adalah bahwa tanda tangan ini menganggap bahwa mereka akan dipanggil dengan lebih dari satu catatan. Seperti yang ditunjukkan oleh pengalaman, mereka sering dipanggil hanya dengan satu argumen, yang harus "dikemas" di dalam slice untuk memenuhi persyaratan tanda tangan fungsi.

Selain itu, karena parameternya idsadalah slice, Anda bisa meneruskan irisan kosong atau nol ke fungsi, dan kompiler akan senang. Ini menambah beban tes tambahan karena pengujian harus mencakup kasus-kasus tersebut.

Untuk memberikan contoh kelas API seperti itu, saya baru-baru ini refactored logika yang membutuhkan instalasi beberapa bidang tambahan jika setidaknya salah satu parameternya bukan nol. Logikanya terlihat seperti ini:

 if svc.MaxConnections > 0 || svc.MaxPendingRequests > 0 || svc.MaxRequests > 0 || svc.MaxRetries > 0 { // apply the non zero parameters } 

Karena operator ifmenjadi sangat panjang, saya ingin menarik logika validasi ke fungsi yang terpisah. Inilah yang saya pikirkan:

 // anyPostive indicates if any value is greater than zero. func anyPositive(values ...int) bool { for _, v := range values { if v > 0 { return true } } return false } 

Ini memungkinkan untuk menyatakan dengan jelas kondisi di mana unit dalam ruangan akan dieksekusi:

 if anyPositive(svc.MaxConnections, svc.MaxPendingRequests, svc.MaxRequests, svc.MaxRetries) { // apply the non zero parameters } 

Namun, ada masalah dengan anyPositive, seseorang dapat secara tidak sengaja menyebutnya seperti ini:

 if anyPositive() { ... } 

Dalam hal ini, anyPositiveakan kembali false. Ini bukan pilihan terburuk. Lebih buruk jika anyPositivedikembalikan truetanpa adanya argumen.

Namun, akan lebih baik untuk dapat mengubah tanda tangan anyPositive untuk memastikan bahwa setidaknya satu argumen diteruskan ke pemanggil. Ini dapat dilakukan dengan menggabungkan parameter untuk argumen normal dan argumen panjang variabel (varargs):

 // anyPostive indicates if any value is greater than zero. func anyPositive(first int, rest ...int) bool { if first > 0 { return true } for _, v := range rest { if v > 0 { return true } } return false } 

Sekarang anyPositiveAnda tidak dapat menelepon dengan kurang dari satu argumen.

6.3. Biarkan fungsi menentukan perilaku yang diinginkan.


Misalkan saya diberi tugas menulis fungsi yang mempertahankan struktur Documentpada disk.

 // Save      f. func Save(f *os.File, doc *Document) error 

Saya bisa menulis fungsi Saveyang menulis Documentke file *os.File. Tetapi ada beberapa masalah.

Tanda tangan Savemenghilangkan kemungkinan merekam data melalui jaringan. Jika persyaratan seperti itu muncul di masa depan, tanda tangan dari fungsi harus diubah, yang akan mempengaruhi semua objek yang memanggil.

Savejuga tidak menyenangkan untuk diuji, karena berfungsi langsung dengan file pada disk. Dengan demikian, untuk memverifikasi operasinya, tes harus membaca konten file setelah menulis.

Dan saya harus memastikan bahwa itu fditulis ke folder sementara dan kemudian dihapus.

*os.Filejuga mendefinisikan banyak metode yang tidak terkait dengan Save, misalnya, membaca direktori dan memeriksa apakah jalur adalah tautan simbolik. Nah, kalau tanda tanganSavehanya dijelaskan bagian yang relevan *os.File.

Apa yang bisa dilakukan?

 // Save      // ReadWriterCloser. func Save(rwc io.ReadWriteCloser, doc *Document) error 

Dengan bantuannya io.ReadWriteCloser, Anda dapat menerapkan prinsip pemisahan antarmuka - dan mendefinisikannya kembali Savepada antarmuka yang menggambarkan properti yang lebih umum dari file tersebut.

Setelah perubahan seperti itu, semua jenis yang mengimplementasikan antarmuka io.ReadWriteCloserdapat diganti dengan yang sebelumnya *os.File.

Ini secara bersamaan memperluas ruang lingkup Savedan mengklarifikasi kepada pemanggil metode tipe apa *os.Fileyang terkait dengan operasinya.

Dan penulis Savetidak dapat lagi memanggil metode yang tidak terkait ini *os.File, karena ia tersembunyi di balik antarmuka io.ReadWriteCloser.

Tetapi kita dapat memperluas prinsip pemisahan antarmuka lebih jauh.

Pertama jikaSave mengikuti prinsip tanggung jawab tunggal, tidak mungkin ia akan membaca file yang baru saja ia tulis untuk memeriksa isinya - kode lain harus melakukan ini.

 // Save      // WriteCloser. func Save(wc io.WriteCloser, doc *Document) error 

Oleh karena itu, Anda dapat mempersempit spesifikasi antarmuka Savehanya dengan menulis dan menutup.

Kedua, mekanisme penutupan utas Saveadalah warisan waktu ketika bekerja dengan file. Pertanyaannya adalah, dalam keadaan apa wcakan ditutup.

Apakah Savepenyebab Closetanpa syarat, baik dalam hal keberhasilan.

Ini menyajikan masalah bagi penelepon karena ia mungkin ingin menambahkan data ke aliran setelah dokumen ditulis.

 // Save      // Writer. func Save(w io.Writer, doc *Document) error 

Opsi terbaik adalah mendefinisikan ulang Hemat untuk bekerja hanya dengan io.Writer, menyimpan operator dari semua fungsi lainnya, kecuali untuk menulis data ke aliran.

Setelah menerapkan prinsip pemisahan antarmuka, fungsi pada saat yang sama menjadi lebih spesifik dalam hal persyaratan (hanya membutuhkan objek di mana ia dapat ditulis), dan lebih umum dalam hal fungsi, karena sekarang kita dapat menggunakannya Saveuntuk menyimpan data di mana pun itu dilaksanakan io.Writer.

7. Penanganan kesalahan


Saya memberikan beberapa presentasi dan menulis banyak tentang topik ini di blog, jadi saya tidak akan mengulanginya. Sebagai gantinya, saya ingin membahas dua bidang lain yang terkait dengan penanganan kesalahan.



7.1. Hilangkan kebutuhan untuk penanganan kesalahan dengan menghapus sendiri kesalahan tersebut


Saya membuat banyak saran untuk meningkatkan sintaks penanganan kesalahan, tetapi opsi terbaik adalah tidak menanganinya sama sekali.

Catatan . Saya tidak mengatakan "hapus penanganan kesalahan". Saya sarankan mengubah kode sehingga tidak ada kesalahan untuk diproses.

Buku filsafat pengembangan perangkat lunak terbaru John Osterhout menginspirasi saya untuk membuat saran ini . Salah satu bab berjudul "Menghilangkan Kesalahan dari Realitas". Mari kita coba terapkan nasihat ini.

7.1.1. Hitungan baris


Kami akan menulis fungsi untuk menghitung jumlah baris dalam file.

 func CountLines(r io.Reader) (int, error) { var ( br = bufio.NewReader(r) lines int err error ) for { _, err = br.ReadString('\n') lines++ if err != nil { break } } if err != io.EOF { return 0, err } return lines, nil } 

Saat kami mengikuti saran dari bagian sebelumnya, CountLinesterima io.Reader, bukan *os.File; sudah menjadi tugas si penelepon untuk menyediakan io.Readerkonten siapa yang ingin kita hitung.

Kami membuat bufio.Reader, dan kemudian memanggil metode dalam satu lingkaran ReadString, meningkatkan penghitung, sampai kami mencapai akhir file, lalu kami mengembalikan jumlah baris yang dibaca.

Setidaknya kami ingin menulis kode seperti itu, tetapi fungsinya dibebani dengan penanganan kesalahan. Misalnya, ada konstruksi yang aneh:

  _, err = br.ReadString('\n') lines++ if err != nil { break } 

Kami menambah jumlah baris sebelum memeriksa kesalahan - ini terlihat aneh.

Alasan kita harus menulisnya dengan cara ini adalah karena itu ReadStringakan mengembalikan kesalahan jika menemui akhir file lebih awal dari karakter baris baru. Ini bisa terjadi jika tidak ada baris baru di akhir file.

Untuk mencoba memperbaikinya, ubah logika penghitung baris, dan kemudian lihat apakah kita perlu keluar dari loop.

Catatan . Logika ini masih belum sempurna, dapatkah Anda menemukan kesalahan?

Namun kami belum selesai memeriksa kesalahan. ReadStringakan kembali io.EOFketika menemui akhir file. Ini adalah situasi yang diharapkan, jadi bagi ReadStringAnda perlu melakukan beberapa cara untuk mengatakan "berhenti, tidak ada lagi yang bisa dibaca." Oleh karena itu, sebelum mengembalikan kesalahan ke objek panggilan CountLine, Anda perlu memeriksa bahwa kesalahan tidak terkait dengan io.EOF, dan kemudian meneruskannya, kalau tidak kita kembali nildan mengatakan bahwa semuanya baik-baik saja.

Saya pikir ini adalah contoh yang bagus dari tesis Russ Cox tentang bagaimana penanganan kesalahan dapat menyembunyikan fungsinya. Mari kita lihat versi yang ditingkatkan.

 func CountLines(r io.Reader) (int, error) { sc := bufio.NewScanner(r) lines := 0 for sc.Scan() { lines++ } return lines, sc.Err() } 

Versi perbaikan ini digunakan bufio.Scannersebagai gantinya bufio.Reader.

Di bawah tenda bufio.Scannermenggunakan bufio.Reader, tetapi menambahkan tingkat abstraksi yang baik, yang membantu menghilangkan penanganan kesalahan.

. bufio.Scanner , .

Metode sc.Scan()mengembalikan nilai truejika pemindai menemukan string dan tidak menemukan kesalahan. Dengan demikian, loop body fordipanggil hanya jika ada garis teks di buffer pemindai. Ini berarti bahwa yang baru CountLinesmenangani kasus ketika tidak ada baris baru atau ketika file kosong.

Kedua, karena ia sc.Scankembali falseketika kesalahan terdeteksi, siklus forberakhir ketika mencapai akhir file atau kesalahan terdeteksi. Tipe bufio.Scannermengingat kesalahan pertama yang ditemui, dan menggunakan metode sc.Err()kami dapat mengembalikan kesalahan itu segera setelah kami keluar dari loop.

Akhirnya, ia sc.Err()mengurus pemrosesan io.EOFdan mengubahnya menjadi niljika akhir file tercapai tanpa kesalahan.

Kiat . Jika Anda mengalami penanganan kesalahan yang berlebihan, cobalah mengekstraksi beberapa operasi menjadi tipe pembantu.

7.1.2. Tanggapan Tanggapan


Contoh kedua saya terinspirasi oleh posting "Kesalahan adalah Nilai . "

Sebelumnya kami melihat contoh bagaimana file dibuka, ditulis, dan ditutup. Ada penanganan kesalahan, tetapi tidak terlalu banyak, karena operasi dapat diringkas dalam pembantu, seperti ioutil.ReadFiledan ioutil.WriteFile. Tetapi ketika bekerja dengan protokol jaringan tingkat rendah, ada kebutuhan untuk membangun jawaban secara langsung menggunakan I / O primitif. Dalam hal ini, penanganan kesalahan dapat menjadi mengganggu. Pertimbangkan fragmen server HTTP yang membuat respons HTTP.

 type Header struct { Key, Value string } type Status struct { Code int Reason string } func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error { _, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason) if err != nil { return err } for _, h := range headers { _, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value) if err != nil { return err } } if _, err := fmt.Fprint(w, "\r\n"); err != nil { return err } _, err = io.Copy(w, body) return err } 

Pertama, bangun bilah status dengan fmt.Fprintfdan periksa kesalahannya. Lalu untuk setiap tajuk kita menulis kunci dan nilai tajuk, setiap kali memeriksa kesalahan. Terakhir, kami menyelesaikan bagian tajuk dengan yang lain \r\n, memeriksa kesalahan, dan menyalin badan tanggapan ke klien. Akhirnya, meskipun kita tidak perlu memeriksa kesalahan dari io.Copy, kita perlu menerjemahkannya dari dua nilai kembali ke satu-satunya yang mengembalikan WriteResponse.

Ini banyak pekerjaan yang monoton. Tetapi Anda dapat meringankan tugas Anda dengan menerapkan jenis pembungkus kecil errWriter.

errWritermemenuhi kontrak io.Writer, sehingga dapat digunakan sebagai pembungkus. errWritermeneruskan rekaman melalui fungsi hingga kesalahan terdeteksi. Dalam hal ini, ia menolak entri dan mengembalikan kesalahan sebelumnya.

 type errWriter struct { io.Writer err error } func (e *errWriter) Write(buf []byte) (int, error) { if e.err != nil { return 0, e.err } var n int n, e.err = e.Writer.Write(buf) return n, nil } func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error { ew := &errWriter{Writer: w} fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason) for _, h := range headers { fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value) } fmt.Fprint(ew, "\r\n") io.Copy(ew, body) return ew.err } 

Jika Anda menerapkan errWriteruntuk WriteResponse, kejelasan kode meningkat secara signifikan. Anda tidak perlu lagi memeriksa kesalahan dalam setiap operasi individu. Pesan kesalahan bergerak ke akhir fungsi sebagai pemeriksaan bidang ew.err, menghindari terjemahan nilai io.Copy yang mengganggu.

7.2. Tangani kesalahan hanya sekali


Akhirnya, saya ingin mencatat bahwa kesalahan harus ditangani hanya sekali. Memproses berarti memeriksa arti kesalahan dan membuat satu keputusan.

 // WriteAll writes the contents of buf to the supplied writer. func WriteAll(w io.Writer, buf []byte) { w.Write(buf) } 

Jika Anda membuat kurang dari satu keputusan, Anda mengabaikan kesalahan. Seperti yang kita lihat di sini, kesalahan dari w.WriteAlldiabaikan.

Tetapi membuat lebih dari satu keputusan dalam menanggapi satu kesalahan juga salah. Di bawah ini adalah kode yang sering saya temui.

 func WriteAll(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil { log.Println("unable to write:", err) // annotated error goes to log file return err // unannotated error returned to caller } return nil } 

Dalam contoh ini, jika kesalahan terjadi selama waktu itu w.Write, baris ditulis ke log, dan juga dikembalikan ke objek panggilan, yang, mungkin, juga akan mencatatnya dan meneruskannya, ke tingkat atas program.

Kemungkinan besar, penelepon melakukan hal yang sama:

 func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { log.Printf("could not marshal config: %v", err) return err } if err := WriteAll(w, buf); err != nil { log.Println("could not write config: %v", err) return err } return nil } 

Dengan demikian, setumpuk baris berulang dibuat dalam log.

 unable to write: io.EOF could not write config: io.EOF 

Tetapi di bagian atas program Anda mendapatkan kesalahan asli tanpa konteks apa pun.

 err := WriteConfig(f, &conf) fmt.Println(err) // io.EOF 

Saya ingin menganalisis topik ini secara lebih rinci, karena saya tidak mempertimbangkan masalah secara bersamaan mengembalikan kesalahan dan masuk ke preferensi pribadi saya.

 func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { log.Printf("could not marshal config: %v", err) // oops, forgot to return } if err := WriteAll(w, buf); err != nil { log.Println("could not write config: %v", err) return err } return nil } 

Saya sering menemui masalah yang lupa dikembalikan oleh programmer dari kesalahan. Seperti yang kami katakan sebelumnya, gaya Go adalah menggunakan operator batas, memeriksa prasyarat saat fungsi dijalankan, dan kembali lebih awal.

Dalam contoh ini, penulis memeriksa kesalahan, mendaftarkannya, tetapi lupa untuk kembali. Karena ini, masalah halus muncul.

Kontrak penanganan kesalahan Go mengatakan bahwa di hadapan kesalahan, tidak ada asumsi yang dapat dibuat tentang isi nilai pengembalian lainnya. Karena penguraian JSON gagal, isinya tidak bufdiketahui: mungkin tidak mengandung apa-apa, tetapi lebih buruk, mungkin berisi fragmen JSON setengah tertulis.

Karena programmer lupa untuk kembali setelah memeriksa dan mendaftarkan kesalahan, buffer yang rusak akan ditransfer WriteAll. Operasi kemungkinan berhasil, dan oleh karena itu file konfigurasi tidak akan ditulis dengan benar. Namun, fungsi selesai secara normal, dan satu-satunya tanda bahwa masalah telah terjadi adalah baris di log di mana JSON marshaling gagal, dan bukan kegagalan catatan konfigurasi.

7.2.1. Menambahkan konteks ke kesalahan


Terjadi kesalahan karena penulis berusaha menambahkan konteks ke pesan kesalahan. Dia mencoba meninggalkan tanda untuk menunjukkan sumber kesalahan.

Mari kita lihat cara lain untuk melakukan hal yang sama fmt.Errorf.

 func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { return fmt.Errorf("could not marshal config: %v", err) } if err := WriteAll(w, buf); err != nil { return fmt.Errorf("could not write config: %v", err) } return nil } func WriteAll(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil { return fmt.Errorf("write failed: %v", err) } return nil } 

Jika Anda menggabungkan catatan kesalahan dengan kembali pada satu baris, lebih sulit untuk lupa untuk kembali dan menghindari kelanjutan yang tidak disengaja.

Jika kesalahan I / O terjadi saat menulis file, metode ini Error()akan menghasilkan sesuatu seperti ini:

 could not write config: write failed: input/output error 

7.2.2. Galat membungkus dengan github.com/pkg/errors


Pola ini fmt.Errorfberfungsi dengan baik untuk merekam pesan kesalahan, tetapi jenis kesalahan berjalan di pinggir jalan. Saya berpendapat bahwa menangani kesalahan sebagai nilai buram penting untuk proyek yang digabungkan secara longgar , jadi jenis kesalahan sumber seharusnya tidak menjadi masalah jika kita hanya perlu bekerja dengan nilainya:

  1. Pastikan itu bukan nol.
  2. Tampilkan di layar atau catat.

Namun, itu terjadi bahwa Anda perlu mengembalikan kesalahan semula. Untuk menjelaskan kesalahan seperti itu, Anda dapat menggunakan sesuatu seperti paket saya errors:

 func ReadFile(path string) ([]byte, error) { f, err := os.Open(path) if err != nil { return nil, errors.Wrap(err, "open failed") } defer f.Close() buf, err := ioutil.ReadAll(f) if err != nil { return nil, errors.Wrap(err, "read failed") } return buf, nil } func ReadConfig() ([]byte, error) { home := os.Getenv("HOME") config, err := ReadFile(filepath.Join(home, ".settings.xml")) return config, errors.WithMessage(err, "could not read config") } func main() { _, err := ReadConfig() if err != nil { fmt.Println(err) os.Exit(1) } } 

Sekarang pesan itu menjadi bug gaya K & D yang bagus:

 could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory 

dan nilainya berisi tautan ke alasan asli.

 func main() { _, err := ReadConfig() if err != nil { fmt.Printf("original error: %T %v\n", errors.Cause(err), errors.Cause(err)) fmt.Printf("stack trace:\n%+v\n", err) os.Exit(1) } } 

Dengan demikian, Anda dapat memulihkan kesalahan asli dan menampilkan jejak tumpukan:

 original error: *os.PathError open /Users/dfc/.settings.xml: no such file or directory stack trace: open /Users/dfc/.settings.xml: no such file or directory open failed main.ReadFile /Users/dfc/devel/practical-go/src/errors/readfile2.go:16 main.ReadConfig /Users/dfc/devel/practical-go/src/errors/readfile2.go:29 main.main /Users/dfc/devel/practical-go/src/errors/readfile2.go:35 runtime.main /Users/dfc/go/src/runtime/proc.go:201 runtime.goexit /Users/dfc/go/src/runtime/asm_amd64.s:1333 could not read config 

Paket ini errorsmemungkinkan Anda untuk menambahkan konteks ke nilai kesalahan dalam format yang nyaman untuk orang dan mesin. Pada presentasi baru-baru ini, saya katakan bahwa dalam rilis Go yang akan datang, bungkus seperti itu akan muncul di perpustakaan standar.

8. Konkurensi


Go sering dipilih karena kemampuan konkurensi. Pengembang melakukan banyak hal untuk meningkatkan efisiensi (dalam hal sumber daya perangkat keras) dan produktivitas, tetapi fungsi paralelisme Go dapat digunakan untuk menulis kode yang tidak produktif dan tidak dapat diandalkan. Pada akhir artikel ini saya ingin memberikan beberapa tips tentang bagaimana menghindari beberapa fungsi jebakan gokart Go.

Dukungan concurrency terkemuka Go disediakan oleh saluran, serta instruksi selectdango. Jika Anda mempelajari teori Go dari buku pelajaran atau di universitas, Anda mungkin telah memperhatikan bahwa bagian paralelisme selalu menjadi yang terakhir dalam kursus. Artikel kami tidak berbeda: saya memutuskan untuk berbicara tentang paralelisme pada akhirnya, sebagai sesuatu yang tambahan untuk keterampilan biasa yang harus dipelajari oleh programmer Go.

Ada dikotomi tertentu di sini, karena fitur utama Go adalah model paralelisme kami yang sederhana dan mudah. Sebagai produk, bahasa kami menjual dirinya sendiri dengan mengorbankan hampir fungsi yang satu ini. Di sisi lain, konkurensi sebenarnya tidak begitu mudah digunakan, jika tidak penulis tidak akan membuatnya menjadi bab terakhir dalam buku mereka, dan kita tidak akan melihat dengan menyesal kode kita.

Bagian ini membahas beberapa jebakan dari penggunaan fungsi konkurensi Go yang naif.

8.1. Lakukan beberapa pekerjaan sepanjang waktu.


Apa masalah dengan program ini?

 package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() for { } } 

Program melakukan apa yang kami maksudkan: ini melayani server web sederhana. Pada saat yang sama, CPU menghabiskan waktu CPU dalam infinite loop, karena for{}pada baris terakhir ia mainmemblokir gorutin utama, tanpa melakukan I / O, tidak ada menunggu pemblokiran, pengiriman atau penerimaan pesan, atau semacam koneksi dengan sheduler.

Karena Go runtime biasanya dilayani oleh sheduler, program ini akan berjalan dengan tidak masuk akal pada prosesor dan dapat berakhir pada kunci aktif (live-lock).

Bagaimana cara memperbaikinya? Ini ada satu pilihan.

 package main import ( "fmt" "log" "net/http" "runtime" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() for { runtime.Gosched() } } 

Ini mungkin terlihat konyol, tetapi ini adalah solusi umum yang saya temui di kehidupan nyata. Ini adalah gejala kesalahpahaman dari masalah yang mendasarinya.

Jika Anda sedikit lebih berpengalaman dengan Go, Anda dapat menulis sesuatu seperti ini.

 package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() select {} } 

Pernyataan kosong selectdiblokir selamanya. Ini berguna, karena sekarang kita tidak memutar seluruh prosesor hanya untuk panggilan runtime.GoSched(). Namun, kami hanya mengobati gejalanya, bukan penyebabnya.

Saya ingin menunjukkan kepada Anda solusi lain, yang, saya harap, sudah terlintas di benak Anda. Alih-alih berlari http.ListenAndServedi goroutine, meninggalkan masalah goroutine utama, jalankan http.ListenAndServedi goroutine utama.

Kiat . Jika Anda keluar dari fungsi main.main, program Go berakhir tanpa syarat, terlepas dari apa yang dijalankan goroutine lain selama eksekusi program.

 package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } } 

Jadi ini adalah saran pertama saya: jika goroutine tidak dapat membuat kemajuan sampai ia menerima hasil dari yang lain, maka seringkali lebih mudah untuk melakukan pekerjaan sendiri, daripada mendelegasikannya.

Ini sering menghilangkan banyak pelacakan negara dan manipulasi saluran yang diperlukan untuk mentransfer hasil kembali dari goroutine ke pemrakarsa proses.

Kiat . Banyak programmer Go menyalahgunakan goroutine, terutama pada awalnya. Seperti semua hal lain dalam hidup, kunci kesuksesan adalah moderasi.

8.2. Serahkan paralelisme kepada penelepon


Apa perbedaan antara kedua API?

 // ListDirectory returns the contents of dir. func ListDirectory(dir string) ([]string, error) 

 // ListDirectory returns a channel over which // directory entries will be published. When the list // of entries is exhausted, the channel will be closed. func ListDirectory(dir string) chan string 

Kami menyebutkan perbedaan yang jelas: contoh pertama membaca direktori menjadi slice, dan kemudian mengembalikan seluruh slice atau kesalahan jika terjadi kesalahan. Ini terjadi secara sinkron, penelepon memblokir ListDirectorysampai semua entri direktori telah dibaca. Bergantung pada seberapa besar direktori tersebut, ini bisa memakan banyak waktu dan berpotensi banyak memori.

Perhatikan contoh kedua. Ini sedikit lebih seperti pemrograman Go klasik, di sini ia ListDirectorymengembalikan saluran melalui mana entri direktori akan dikirim. Ketika saluran ditutup, ini adalah tanda bahwa tidak ada lagi entri katalog. Karena pengisian saluran terjadi setelah pengembalian ListDirectory, dapat diasumsikan bahwa goroutine mulai mengisi saluran.

Catatan . Pada opsi kedua, tidak perlu menggunakan goroutine: Anda dapat memilih saluran yang cukup untuk menyimpan semua entri direktori tanpa memblokir, mengisinya, menutupnya, dan kemudian mengembalikan saluran ke pemanggil. Tapi ini tidak mungkin, karena dalam kasus ini masalah yang sama akan muncul ketika menggunakan sejumlah besar memori untuk buffer semua hasil di saluran.

Versi ListDirectorysaluran memiliki dua masalah lagi:

  • Menggunakan saluran tertutup sebagai sinyal bahwa tidak ada lagi elemen untuk diproses, ListDirectorytidak dapat memberi tahu pemanggil serangkaian elemen yang tidak lengkap karena kesalahan. Penelepon tidak memiliki cara untuk menyampaikan perbedaan antara direktori kosong dan kesalahan. Dalam kedua kasus, tampaknya saluran akan segera ditutup.
  • Penelepon harus terus membaca dari saluran ketika ditutup, karena ini adalah satu-satunya cara untuk memahami bahwa saluran yang mengisi goroutine telah berhenti berfungsi. Ini adalah batasan serius dalam penggunaan ListDirectory: pemanggil menghabiskan waktu membaca dari saluran, bahkan jika ia menerima semua data yang diperlukan. Ini mungkin lebih efisien dalam hal penggunaan memori untuk direktori menengah dan besar, tetapi metode ini tidak lebih cepat dari metode berbasis slice asli.

Dalam kedua kasus, solusinya adalah dengan menggunakan callback: fungsi yang dipanggil dalam konteks setiap entri direktori saat dijalankan.

 func ListDirectory(dir string, fn func(string)) 

Tidak mengherankan, fungsinya filepath.WalkDirbekerja seperti itu.

Kiat . Jika fungsi Anda meluncurkan goroutine, Anda harus memberi pemanggil cara untuk menghentikan rutinitas ini secara eksplisit. Sering kali paling mudah untuk meninggalkan mode eksekusi asinkron pada pemanggil.

8.3. Jangan pernah menjalankan goroutine tanpa tahu kapan akan berhenti


Pada contoh sebelumnya, goroutine digunakan secara tidak perlu. Tetapi salah satu kekuatan utama Go adalah kemampuan konkurensi kelasnya. Memang, dalam banyak kasus kerja paralel cukup tepat, dan kemudian perlu menggunakan goroutine.

Aplikasi sederhana ini melayani lalu lintas http pada dua port berbeda: port 8080 untuk lalu lintas aplikasi dan port 8001 untuk akses ke titik akhir /debug/pprof.

 package main import ( "fmt" "net/http" _ "net/http/pprof" ) func main() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) go http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) // debug http.ListenAndServe("0.0.0.0:8080", mux) // app traffic } 

Meskipun program ini tidak rumit, ini adalah dasar dari aplikasi nyata.

Aplikasi dalam bentuk saat ini memiliki beberapa masalah yang akan muncul saat mereka tumbuh, jadi mari kita segera melihat beberapa dari mereka.

 func serveApp() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) http.ListenAndServe("0.0.0.0:8080", mux) } func serveDebug() { http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) } func main() { go serveDebug() serveApp() } 

penangan melanggar serveAppdan serveDebuguntuk fungsi yang terpisah, kita telah memisahkan mereka dari main.main. Kami juga mengikuti saran sebelumnya dan memastikan serveAppdan serveDebugmeninggalkan tugas untuk memastikan paralelisme pemanggil.

Tetapi ada beberapa masalah dengan kinerja program semacam itu. Jika kita keluar serveAppdan kemudian keluar main.main, maka program berakhir dan akan dimulai kembali oleh manajer proses.

Kiat . Seperti halnya fungsi di Go, berikan paralelisme kepada pemanggil, sehingga aplikasi harus berhenti memantau statusnya dan memulai kembali program yang memanggilnya. Jangan membuat aplikasi Anda bertanggung jawab untuk me-restart sendiri: prosedur ini paling baik ditangani dari luar aplikasi.

Namun, itu serveDebugdimulai pada goroutine yang terpisah, dan dalam kasus pelepasannya, goroutine berakhir, sementara sisa program berlanjut. Pengembang Anda tidak akan menyukai kenyataan bahwa Anda tidak bisa mendapatkan statistik aplikasi karena pawang /debugtelah lama berhenti bekerja.

Kami perlu memastikan aplikasi ditutup jika ada goroutine yang melayani berhenti .

 func serveApp() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) if err := http.ListenAndServe("0.0.0.0:8080", mux); err != nil { log.Fatal(err) } } func serveDebug() { if err := http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux); err != nil { log.Fatal(err) } } func main() { go serveDebug() go serveApp() select {} } 

Sekarang serverAppmereka serveDebugmemeriksa kesalahan dari ListenAndServedan, jika perlu, memanggil mereka log.Fatal. Karena kedua penangan bekerja di goroutine, kami menyusun rutinitas utama di select{}.

Pendekatan ini memiliki sejumlah masalah:

  1. Jika ListenAndServekembali dengan kesalahan nil, tidak akan ada panggilan log.Fatal, dan layanan HTTP pada port ini akan keluar tanpa menghentikan aplikasi.
  2. log.Fatalpanggilan os.Exityang keluar tanpa syarat dari program; panggilan yang ditangguhkan tidak akan berfungsi, goroutine lain tidak akan diberitahu tentang penutupan, program hanya akan berhenti. Ini membuatnya sulit untuk menulis tes untuk fungsi-fungsi ini.

Kiat . Gunakan hanya log.Fatalpada fungsi main.mainatau init.

Bahkan, kami ingin menyampaikan kesalahan yang terjadi pada pencipta goroutine, sehingga ia dapat mengetahui mengapa dia berhenti dan menyelesaikan proses dengan bersih.

 func serveApp() error { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) return http.ListenAndServe("0.0.0.0:8080", mux) } func serveDebug() error { return http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) } func main() { done := make(chan error, 2) go func() { done <- serveDebug() }() go func() { done <- serveApp() }() for i := 0; i < cap(done); i++ { if err := <-done; err != nil { fmt.Println("error: %v", err) } } } 

Status pengembalian goroutine dapat diperoleh melalui saluran. Ukuran saluran sama dengan jumlah goroutine yang ingin kita kontrol, jadi pengiriman ke saluran donetidak akan diblokir, karena ini akan memblokir penutupan goroutine dan menyebabkan kebocoran.

Karena saluran donetidak dapat ditutup dengan aman, kami tidak dapat menggunakan idiom untuk siklus saluran for rangesampai semua goroutine melaporkan. Sebagai gantinya, kami menjalankan semua goroutine yang berjalan dalam satu siklus, yang sama dengan kapasitas saluran.

Sekarang kita memiliki cara untuk membersihkan setiap goroutine dengan bersih dan memperbaiki semua kesalahan yang mereka temui. Tinggal mengirim sinyal untuk menyelesaikan pekerjaan dari goroutine pertama ke orang lain.

Banding untukhttp.Servertentang penyelesaian, jadi saya membungkus logika ini dalam fungsi pembantu. Helper servemenerima alamat dan http.Handler, juga http.ListenAndServe, saluran stopyang kami gunakan untuk menjalankan metode Shutdown.

 func serve(addr string, handler http.Handler, stop <-chan struct{}) error { s := http.Server{ Addr: addr, Handler: handler, } go func() { <-stop // wait for stop signal s.Shutdown(context.Background()) }() return s.ListenAndServe() } func serveApp(stop <-chan struct{}) error { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) return serve("0.0.0.0:8080", mux, stop) } func serveDebug(stop <-chan struct{}) error { return serve("127.0.0.1:8001", http.DefaultServeMux, stop) } func main() { done := make(chan error, 2) stop := make(chan struct{}) go func() { done <- serveDebug(stop) }() go func() { done <- serveApp(stop) }() var stopped bool for i := 0; i < cap(done); i++ { if err := <-done; err != nil { fmt.Println("error: %v", err) } if !stopped { stopped = true close(stop) } } } 

Sekarang untuk setiap nilai di saluran donekami menutup saluran stop, yang membuat setiap gorutin di saluran ini menutup sendiri http.Server. Pada gilirannya, ini mengarah pada pengembalian semua goroutine yang tersisa ListenAndServe. Ketika semua gorut yang berjalan telah berhenti, itu main.mainberakhir dan proses berhenti dengan bersih.

Kiat . Menulis logika seperti itu sendiri adalah pekerjaan berulang dan risiko kesalahan. Lihatlah paket seperti ini yang akan melakukan sebagian besar pekerjaan untuk Anda.

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


All Articles