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:
- Kesederhanaan
- Keterbacaan
- 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 }
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 {
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.
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:
- Jelaskan apa yang dilakukan kode.
- Jelaskan bagaimana dia melakukannya.
- Jelaskan mengapa .
Bentuk pertama sangat ideal untuk mengomentari karakter publik:
Yang kedua sangat ideal untuk komentar di dalam suatu metode:
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{
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
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.
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.
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
Ada satu pengecualian untuk aturan ini: Anda tidak perlu mendokumentasikan metode yang mengimplementasikan antarmuka. Secara khusus, jangan lakukan ini:
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
.
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.
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:- .
- . , .
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 utils
atau helpers
biasanya 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, strings
untuk utilitas pemrosesan string.
Paket dengan nama-nama seperti base
atau common
sering 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/http
tidak melakukan paket-paket individual client
dan server
, sebaliknya, ada file client.go
dan server.go
dengan tipe data yang sesuai, serta transport.go
untuk total transportasi.Kiat . Penting untuk diingat bahwa nama pengenal termasuk nama paket.
- Fungsi
Get
dari suatu paket net/http
menjadi http.Get
tautan dari paket lain.
- Suatu jenis
Reader
dari suatu paket strings
ditransformasikan menjadi ketika diimpor ke dalam paket lain strings.Reader
.
- Antarmuka
Error
dari paket net
jelas 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 try
dan 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.lastRead
dan jika operasi sebelumnya tidak ReadRune
, maka kesalahan segera dikembalikan. Sisa fungsi bekerja berdasarkan apa yang b.lastRead
lebih 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 nil
harus 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.Mutex
yang 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
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 len
keduanya cap
sama 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() {
Catatan . var s []string
mirip 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:- Gunakan antarmuka untuk menggambarkan perilaku yang diperlukan oleh fungsi atau metode.
- 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:- Pindahkan variabel terkait sebagai bidang ke struktur yang membutuhkannya.
- 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 common
pada akhirnya terkait erat dengan konsumen terbesar, dan ini membuat sulit untuk melakukan koreksi ke versi sebelumnya (perbaikan port-belakang) tanpa memperbarui kedua common
dan 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/contour
yang 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
, private
dan 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 .go
menjadi 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 http
harus dalam file http.go
di direktori http
.
- Saat paket bertambah, Anda dapat membagi berbagai fungsi menjadi beberapa file. Sebagai contoh, file tersebut
messages.go
akan berisi tipe Request
dan Response
, client.go
tipe Client
file 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 go
mendukung paket testing
di dua tempat. Jika Anda memiliki paket http2
, Anda dapat menulis file http2_test.go
dan 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 go
juga 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 go
mengenali 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 go
melihat 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/f
hanya dapat mengimpor paket dari pohon direktori .../a/b/c
, tetapi tidak sama sekali .../a/b/g
atau repositori lainnya (lihatdokumentasi ).5.2. Paket utama terkecil
Fungsi main
dan paket main
harus memiliki fungsionalitas minimal, karena main.main
berfungsi seperti singleton: program hanya dapat memiliki satu fungsi main
, termasuk tes.Karena itu main.main
singleton, ada banyak batasan pada objek yang disebut: mereka dipanggil hanya selama main.main
atau 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)
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 CopyFile
selalu 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
menerima dua parameter: alamat TCP untuk mendengarkan koneksi yang masuk dan http.Handler
untuk memproses permintaan HTTP yang masuk. Serve
memungkinkan parameter kedua menjadi nil
. Dalam komentar, dicatat bahwa biasanya objek pemanggil memang akan lewat nil
, menunjukkan keinginan untuk menggunakannya http.DefaultServeMux
sebagai parameter implisit.Sekarang si penelepon Serve
memiliki 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 nil
menyebar seperti virus. Paket ini juga http
memiliki 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 ListenAndServe
memungkinkan pemanggil lulus nil
untuk parameter kedua, itu http.Serve
juga mendukung perilaku ini. Bahkan, itu dalam http.Serve
logika yang diterapkan "jika handler sama nil
, gunakan DefaultServeMux
". Penerimaan nil
untuk satu parameter dapat membuat penelepon berpikir bahwa itu dapat diteruskan nil
untuk kedua parameter. Tapi begitulahServe
http.Serve(nil, nil)
menyebabkan kepanikan yang mengerikan.Kiat . Jangan mencampur parameter dalam tanda tangan fungsi yang sama nil
dan tidak nil
.
Penulis http.ListenAndServe
mencoba menyederhanakan kehidupan pengguna API untuk kasus default, tetapi keamanan terpengaruh.Di hadapan, nil
tidak 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 ids
adalah 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 {
Karena operator if
menjadi sangat panjang, saya ingin menarik logika validasi ke fungsi yang terpisah. Inilah yang saya pikirkan:
Ini memungkinkan untuk menyatakan dengan jelas kondisi di mana unit dalam ruangan akan dieksekusi: if anyPositive(svc.MaxConnections, svc.MaxPendingRequests, svc.MaxRequests, svc.MaxRetries) {
Namun, ada masalah dengan anyPositive
, seseorang dapat secara tidak sengaja menyebutnya seperti ini: if anyPositive() { ... }
Dalam hal ini, anyPositive
akan kembali false
. Ini bukan pilihan terburuk. Lebih buruk jika anyPositive
dikembalikan true
tanpa 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):
Sekarang anyPositive
Anda 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 Document
pada disk.
Saya bisa menulis fungsi Save
yang menulis Document
ke file *os.File
. Tetapi ada beberapa masalah.Tanda tangan Save
menghilangkan 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.Save
juga 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 f
ditulis ke folder sementara dan kemudian dihapus.*os.File
juga mendefinisikan banyak metode yang tidak terkait dengan Save
, misalnya, membaca direktori dan memeriksa apakah jalur adalah tautan simbolik. Nah, kalau tanda tanganSave
hanya dijelaskan bagian yang relevan *os.File
.Apa yang bisa dilakukan?
Dengan bantuannya io.ReadWriteCloser
, Anda dapat menerapkan prinsip pemisahan antarmuka - dan mendefinisikannya kembali Save
pada antarmuka yang menggambarkan properti yang lebih umum dari file tersebut.Setelah perubahan seperti itu, semua jenis yang mengimplementasikan antarmuka io.ReadWriteCloser
dapat diganti dengan yang sebelumnya *os.File
.Ini secara bersamaan memperluas ruang lingkup Save
dan mengklarifikasi kepada pemanggil metode tipe apa *os.File
yang terkait dengan operasinya.Dan penulis Save
tidak 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.
Oleh karena itu, Anda dapat mempersempit spesifikasi antarmuka Save
hanya dengan menulis dan menutup.Kedua, mekanisme penutupan utas Save
adalah warisan waktu ketika bekerja dengan file. Pertanyaannya adalah, dalam keadaan apa wc
akan ditutup.Apakah Save
penyebab Close
tanpa syarat, baik dalam hal keberhasilan.Ini menyajikan masalah bagi penelepon karena ia mungkin ingin menambahkan data ke aliran setelah dokumen ditulis.
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 Save
untuk 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, CountLines
terima io.Reader
, bukan *os.File
; sudah menjadi tugas si penelepon untuk menyediakan io.Reader
konten 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 ReadString
akan 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. ReadString
akan kembali io.EOF
ketika menemui akhir file. Ini adalah situasi yang diharapkan, jadi bagi ReadString
Anda 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 nil
dan 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.Scanner
sebagai gantinya bufio.Reader
.Di bawah tenda bufio.Scanner
menggunakan bufio.Reader
, tetapi menambahkan tingkat abstraksi yang baik, yang membantu menghilangkan penanganan kesalahan.. bufio.Scanner
, .
Metode sc.Scan()
mengembalikan nilai true
jika pemindai menemukan string dan tidak menemukan kesalahan. Dengan demikian, loop body for
dipanggil hanya jika ada garis teks di buffer pemindai. Ini berarti bahwa yang baru CountLines
menangani kasus ketika tidak ada baris baru atau ketika file kosong.Kedua, karena ia sc.Scan
kembali false
ketika kesalahan terdeteksi, siklus for
berakhir ketika mencapai akhir file atau kesalahan terdeteksi. Tipe bufio.Scanner
mengingat 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.EOF
dan mengubahnya menjadi nil
jika 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.ReadFile
dan 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.Fprintf
dan 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
.errWriter
memenuhi kontrak io.Writer
, sehingga dapat digunakan sebagai pembungkus. errWriter
meneruskan 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 errWriter
untuk 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.
Jika Anda membuat kurang dari satu keputusan, Anda mengabaikan kesalahan. Seperti yang kita lihat di sini, kesalahan dari w.WriteAll
diabaikan.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)
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)
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)
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 buf
diketahui: 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.Errorf
berfungsi 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:- Pastikan itu bukan nol.
- 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 errors
memungkinkan 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 select
dango
. 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 main
memblokir 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 select
diblokir 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.ListenAndServe
di goroutine, meninggalkan masalah goroutine utama, jalankan http.ListenAndServe
di 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?
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 ListDirectory
sampai 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 ListDirectory
mengembalikan 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 ListDirectory
saluran memiliki dua masalah lagi:- Menggunakan saluran tertutup sebagai sinyal bahwa tidak ada lagi elemen untuk diproses,
ListDirectory
tidak 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.WalkDir
bekerja 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)
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 serveApp
dan serveDebug
untuk fungsi yang terpisah, kita telah memisahkan mereka dari main.main
. Kami juga mengikuti saran sebelumnya dan memastikan serveApp
dan serveDebug
meninggalkan tugas untuk memastikan paralelisme pemanggil.Tetapi ada beberapa masalah dengan kinerja program semacam itu. Jika kita keluar serveApp
dan 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 serveDebug
dimulai 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 /debug
telah 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 serverApp
mereka serveDebug
memeriksa kesalahan dari ListenAndServe
dan, jika perlu, memanggil mereka log.Fatal
. Karena kedua penangan bekerja di goroutine, kami menyusun rutinitas utama di select{}
.Pendekatan ini memiliki sejumlah masalah:- Jika
ListenAndServe
kembali dengan kesalahan nil
, tidak akan ada panggilan log.Fatal
, dan layanan HTTP pada port ini akan keluar tanpa menghentikan aplikasi.
log.Fatal
panggilan os.Exit
yang 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.Fatal
pada fungsi main.main
atau 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 done
tidak akan diblokir, karena ini akan memblokir penutupan goroutine dan menyebabkan kebocoran.Karena saluran done
tidak dapat ditutup dengan aman, kami tidak dapat menggunakan idiom untuk siklus saluran for range
sampai 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.Server
tentang penyelesaian, jadi saya membungkus logika ini dalam fungsi pembantu. Helper serve
menerima alamat dan http.Handler
, juga http.ListenAndServe
, saluran stop
yang 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
Sekarang untuk setiap nilai di saluran done
kami 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.main
berakhir 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.