Tips Buruk untuk Programer Go

Setelah beberapa dekade pemrograman di Jawa, beberapa tahun terakhir saya terutama bekerja pada Go. Bekerja dengan Go itu bagus, terutama karena kodenya sangat mudah diikuti. Java telah menyederhanakan model pemrograman C ++ dengan menghapus banyak pewarisan, manajemen memori manual, dan overloading operator. Go melakukan hal yang sama, terus bergerak menuju gaya pemrograman yang sederhana dan dapat dipahami, sepenuhnya menghapus warisan dan kelebihan fungsi. Kode sederhana adalah kode yang dapat dibaca, dan kode yang dapat dibaca adalah kode yang didukung. Dan ini bagus untuk perusahaan dan karyawan saya.
Seperti dalam semua budaya, pengembangan perangkat lunak memiliki legenda sendiri, cerita yang diceritakan kembali oleh pendingin air. Kita semua telah mendengar tentang pengembang yang, alih-alih berfokus untuk menciptakan produk yang berkualitas, terpaku untuk melindungi pekerjaan mereka sendiri dari orang luar. Mereka tidak memerlukan kode yang didukung, karena itu berarti orang lain akan dapat memahami dan memodifikasinya. Apakah mungkin on Go? Apakah mungkin membuat kode Go begitu rumit? Saya akan katakan segera - ini bukan tugas yang mudah. Mari kita lihat opsi yang mungkin.
Anda berpikir: “
Seberapa banyak Anda bisa mendapatkan kode dalam bahasa pemrograman? Apakah mungkin untuk menulis kode mengerikan di Go sehingga penulisnya menjadi sangat diperlukan di perusahaan? »Jangan khawatir. Ketika saya masih mahasiswa, saya memiliki proyek di mana saya mendukung kode Lisp-e orang lain yang ditulis oleh seorang mahasiswa pascasarjana. Bahkan, ia berhasil menulis kode Fortran-e menggunakan Lisp. Kode tersebut terlihat seperti ini:
(defun add-mult-pi (in1 in2) (setq a in1) (setq b in2) (setq c (+ ab)) (setq d (* 3.1415 c) d )
Ada puluhan file kode seperti itu. Dia benar-benar mengerikan dan sekaligus brilian. Saya menghabiskan waktu berbulan-bulan untuk mencari tahu. Dibandingkan dengan ini, menulis kode buruk di Go hanya meludah.
Ada banyak cara untuk membuat kode Anda tidak didukung, tetapi kami hanya akan melihat beberapa. Untuk melakukan kejahatan, Anda harus terlebih dahulu belajar berbuat baik. Oleh karena itu, pertama-tama kita melihat bagaimana programmer "baik" Go menulis, dan kemudian kita melihat bagaimana melakukan yang sebaliknya.
Kemasan buruk
Paket adalah topik praktis untuk memulai. Bagaimana kode organisasi dapat merusak keterbacaan?
Di Go, nama paket digunakan untuk merujuk ke entitas yang diekspor (misalnya, `
fmt.Println` atau` http.RegisterFunc` ). Karena kita dapat melihat nama paketnya, programmer Go yang “baik” memastikan bahwa nama ini menggambarkan apa entitas yang diekspor. Kita seharusnya tidak memiliki paket util, karena nama seperti `
util.JSONMarshal` tidak akan bekerja untuk kita - kita
perlu` json.Marshal` .
Pengembang "baik" Go juga tidak membuat paket terpisah untuk DAO atau model. Bagi mereka yang tidak terbiasa dengan istilah ini, DAO adalah "
objek akses data " - lapisan kode yang berinteraksi dengan database Anda. Saya dulu bekerja untuk sebuah perusahaan di mana 6 layanan Java mengimpor perpustakaan DAO yang sama untuk mengakses database yang sama, yang mereka bagikan, karena "
... yah, Anda tahu, layanan-layanan mikro adalah sama ... ".
Jika Anda memiliki paket terpisah dengan semua DAO Anda, maka kemungkinan besar Anda akan mendapatkan ketergantungan melingkar antar paket, yang dilarang di Go. Dan jika Anda memiliki beberapa layanan yang menyertakan paket DAO ini sebagai perpustakaan, Anda juga dapat menghadapi situasi di mana perubahan dalam satu layanan mengharuskan memperbarui semua layanan Anda, jika tidak, sesuatu akan rusak. Ini disebut monolith terdistribusi dan sangat sulit untuk diperbarui.
Ketika Anda tahu bagaimana pengemasan seharusnya bekerja dan apa yang memperburuknya, “mulai melayani kejahatan” menjadi sederhana. Mengatur kode Anda dengan buruk dan memberikan nama buruk paket Anda. Pecah kode Anda menjadi paket-paket seperti
model ,
util dan
dao . Jika Anda benar-benar ingin mulai membuat kekacauan, cobalah membuat paket untuk menghormati kucing Anda atau warna favorit Anda. Ketika orang dihadapkan dengan dependensi siklik atau monolit terdistribusi karena mencoba menggunakan kode Anda, Anda harus mendesah, memutar mata, dan memberi tahu mereka bahwa mereka hanya melakukan kesalahan ...
Antarmuka yang tidak pantas
Sekarang semua paket kami rusak, kita bisa beralih ke antarmuka. Antarmuka di Go tidak seperti antarmuka dalam bahasa lain. Fakta bahwa Anda tidak secara eksplisit menyatakan bahwa jenis ini mengimplementasikan antarmuka pada awalnya tampaknya tidak signifikan, tetapi pada kenyataannya itu benar-benar membalikkan konsep antarmuka.
Dalam sebagian besar bahasa dengan tipe abstrak, antarmuka didefinisikan sebelum atau pada saat yang sama dengan implementasi. Anda harus melakukan ini setidaknya untuk pengujian. Jika Anda tidak membuat antarmuka terlebih dahulu, Anda tidak bisa memasukkannya nanti tanpa merusak semua kode yang menggunakan kelas ini. Karena Anda harus menulis ulang dengan tautan ke antarmuka, bukan tipe tertentu.
Untuk alasan ini, kode Java sering memiliki antarmuka layanan raksasa dengan banyak metode. Kelas yang mengimplementasikan antarmuka ini kemudian menggunakan metode yang mereka butuhkan dan mengabaikan sisanya. Tes menulis dimungkinkan, tetapi Anda menambahkan tingkat abstraksi tambahan, dan saat menulis tes, Anda sering menggunakan alat untuk menghasilkan implementasi metode-metode yang tidak Anda butuhkan.
Di Go, antarmuka implisit menentukan metode mana yang perlu Anda gunakan. Kode memiliki antarmuka, bukan sebaliknya. Bahkan jika Anda menggunakan tipe dengan banyak metode yang ditentukan di dalamnya, Anda dapat menentukan antarmuka yang hanya mencakup metode yang Anda butuhkan. Kode lain yang menggunakan bidang terpisah dari jenis yang sama akan menentukan antarmuka lain yang hanya mencakup fungsionalitas yang diperlukan. Biasanya, antarmuka ini hanya memiliki beberapa metode.
Ini membuatnya lebih mudah untuk memahami kode Anda, karena deklarasi metode tidak hanya menentukan data apa yang dibutuhkan, tetapi juga secara akurat menunjukkan fungsionalitas apa yang akan digunakan. Ini adalah salah satu alasan mengapa pengembang Go yang baik mengikuti saran: "
Terima antarmuka, kembalikan struktur ."
Tetapi hanya karena ini adalah praktik yang baik tidak berarti Anda harus melakukan itu ...
Cara terbaik untuk membuat antarmuka Anda "jahat" adalah kembali ke prinsip-prinsip menggunakan antarmuka dari bahasa lain, mis. Tentukan antarmuka terlebih dahulu sebagai bagian dari kode yang dipanggil. Tentukan antarmuka besar dengan banyak metode yang digunakan oleh semua klien layanan. Tidak jelas metode apa yang benar-benar dibutuhkan. Ini menyulitkan kode, dan komplikasi, seperti yang Anda tahu, adalah teman terbaik seorang programmer "jahat".
Pass pointer tumpukan
Sebelum menjelaskan apa artinya ini, Anda perlu sedikit berfilsafat. Jika Anda mengalihkan perhatian dan berpikir, setiap program tertulis melakukan hal yang sama. Ini menerima data, memprosesnya, dan kemudian mengirim data yang diproses ke lokasi lain. Begitulah, terlepas dari apakah Anda menulis sistem penggajian, menerima permintaan HTTP dan mengembalikan halaman web, atau bahkan memeriksa joystick untuk melacak klik tombol - program memproses data.
Jika kita melihat program dengan cara ini, hal terpenting yang harus dilakukan adalah memastikan bahwa mudah bagi kita untuk memahami bagaimana data dikonversi. Dan jadi itu praktik yang baik untuk menjaga data tidak berubah selama mungkin selama program. Karena data yang tidak berubah adalah data yang mudah dilacak.
Di Go, kami memiliki tipe referensi dan tipe nilai. Perbedaan antara keduanya adalah apakah variabel mengacu pada salinan data atau ke lokasi data dalam memori. Pointer, irisan, peta, saluran, antarmuka, dan fungsi adalah tipe referensi, dan yang lainnya adalah tipe nilai. Jika Anda menetapkan variabel tipe nilai ke variabel lain, itu membuat salinan nilai; mengubah satu variabel tidak mengubah nilai lainnya.
Menetapkan satu variabel dari tipe referensi ke variabel lain dari tipe referensi berarti keduanya memiliki area memori yang sama, jadi jika Anda mengubah data yang ditunjuk pertama, Anda mengubah data yang ditunjuk kedua. Ini berlaku untuk variabel lokal dan parameter fungsi.
func main() {
Pengembang Kind Go ingin membuatnya lebih mudah untuk memahami bagaimana data dikumpulkan. Mereka mencoba menggunakan tipe nilai sebagai parameter fungsi sesering mungkin. Tidak ada cara di Go untuk menandai bidang dalam struktur atau parameter fungsi sebagai final. Jika suatu fungsi menggunakan parameter nilai, mengubah parameter tidak akan mengubah variabel dalam fungsi panggilan. Yang bisa dilakukan fungsi yang dipanggil adalah mengembalikan nilai ke fungsi panggilan. Jadi, jika Anda mengisi struktur dengan memanggil fungsi dengan parameter nilai, Anda tidak perlu takut untuk mentransfer data ke struktur, karena Anda memahami dari mana setiap bidang dalam struktur berasal.
type Foo struct { A int B string } func getA() int { return 20 } func getB(i int) string { return fmt.Sprintf("%d",i*2) } func main() { f := Foo{} fA = getA() fB = getB(fA)
Nah, bagaimana kita menjadi "jahat"? Sangat sederhana - membalikkan model ini.
Alih-alih memanggil fungsi yang mengembalikan nilai yang diinginkan, Anda meneruskan pointer ke struktur dalam fungsi dan memungkinkan mereka untuk membuat perubahan pada struktur. Karena setiap fungsi memiliki strukturnya sendiri, satu-satunya cara untuk mengetahui bidang mana yang berubah adalah dengan melihat seluruh kode. Anda mungkin juga memiliki dependensi implisit antara fungsi - fungsi 1 mentransfer data yang dibutuhkan oleh fungsi 2. Namun dalam kode itu sendiri, tidak ada yang menunjukkan bahwa Anda harus memanggil fungsi 1 terlebih dahulu. Jika Anda membangun struktur data Anda dengan cara ini, Anda dapat yakin bahwa tidak ada yang akan mengerti apa yang dilakukan kode Anda.
type Foo struct { A int B string } func setA(f *Foo) { fA = 20 }
Permukaan panik
Sekarang kita mulai menangani kesalahan. Anda mungkin berpikir bahwa menulis program yang menangani kesalahan sekitar 75% buruk, dan saya tidak akan mengatakan bahwa Anda salah. Kode Go sering diisi dengan penanganan kesalahan head-to-toe. Dan tentu saja, akan lebih mudah untuk memprosesnya tidak secara langsung. Kesalahan terjadi, dan menanganinya adalah hal yang membedakan profesional dari pemula. Penanganan kesalahan yang lambat menyebabkan program tidak stabil yang sulit untuk di-debug dan sulit untuk dipelihara. Terkadang menjadi seorang programmer yang “baik” berarti “menyusahkan”.
func (dus DBUserService) Load(id int) (User, error) { rows, err := dus.DB.Query("SELECT name FROM USERS WHERE ID = ?", id) if err != nil { return User{}, err } if !rows.Next() { return User{}, fmt.Errorf("no user for id %d", id) } var name string err = rows.Scan(&name) if err != nil { return User{}, err } err = rows.Close() if err != nil { return User{}, err } return User{Id: id, Name: name}, nil }
Banyak bahasa, seperti C ++, Python, Ruby, dan Java, menggunakan pengecualian untuk menangani kesalahan. Jika terjadi kesalahan, pengembang dalam bahasa ini melempar atau melempar pengecualian, mengharapkan beberapa kode untuk menanganinya. Tentu saja, program mengharapkan bahwa klien menyadari kemungkinan kesalahan dilemparkan ke lokasi tertentu sehingga dimungkinkan untuk melempar pengecualian. Karena, kecuali (dengan tidak ada permainan kata-kata) Java memeriksa pengecualian, tidak ada dalam tanda tangan metode dalam bahasa atau fungsi untuk menunjukkan bahwa pengecualian dapat terjadi. Jadi, bagaimana pengembang tahu pengecualian mana yang perlu dikhawatirkan? Mereka memiliki dua opsi:
- Pertama, mereka dapat membaca semua kode sumber dari semua perpustakaan yang dipanggil oleh kode mereka, dan semua perpustakaan yang memanggil perpustakaan yang disebut, dll.
- Kedua, mereka bisa mempercayai dokumentasinya. Saya mungkin bias, tetapi pengalaman pribadi tidak memungkinkan saya untuk sepenuhnya mempercayai dokumentasi.
Jadi, bagaimana kita membawa kejahatan ini? Menyalahgunakan panik (
panik ) dan pemulihan (
pulih ), tentu saja! Panik dirancang untuk situasi seperti "drive jatuh" atau "kartu jaringan meledak." Tapi tidak untuk itu - "seseorang melewati string, bukan int".
Sayangnya, yang lain, "pengembang yang kurang tercerahkan" akan mengembalikan kesalahan dari kode mereka. Karena itu, inilah fungsi pembantu kecil dari PanicIfErr. Gunakan itu untuk mengubah kesalahan pengembang lain menjadi panik.
func PanicIfErr(err error) { if err != nil { panic(err) } }
Anda dapat menggunakan PanicIfErr untuk membungkus kesalahan orang lain, kompres kode. Tidak ada lagi penanganan kesalahan yang buruk! Kesalahan apa pun sekarang menjadi panik. Sangat produktif!
func (dus DBUserService) LoadEvil(id int) User { rows, err := dus.DB.Query( "SELECT name FROM USERS WHERE ID = ?", id) PanicIfErr(err) if !rows.Next() { panic(fmt.Sprintf("no user for id %d", id)) } var name string PanicIfErr(rows.Scan(&name)) PanicIfErr(rows.Close()) return User{Id: id, Name: name} }
Anda dapat menempatkan pemulihan di suatu tempat lebih dekat ke awal program, mungkin di
middleware Anda sendiri. Dan kemudian katakan bahwa Anda tidak hanya memproses kesalahan, tetapi juga membuat pembersih kode orang lain. Melakukan kejahatan dengan berbuat baik adalah jenis kejahatan terbaik.
func PanicMiddleware(h http.Handler) http.Handler { return http.HandlerFunc( func(rw http.ResponseWriter, req *http.Request){ defer func() { if r := recover(); r != nil { fmt.Println(", - .") } }() h.ServeHTTP(rw, req) } ) }
Mengatur efek samping
Selanjutnya kita akan membuat efek samping. Ingat, pengembang Go yang “baik” ingin memahami bagaimana data melewati program. Cara terbaik untuk mengetahui data yang dilaluinya adalah dengan mengatur dependensi eksplisit dalam aplikasi. Bahkan entitas yang berhubungan dengan antarmuka yang sama dapat sangat bervariasi dalam perilaku. Misalnya, kode yang menyimpan data dalam memori, dan kode yang mengakses database untuk pekerjaan yang sama. Namun, ada cara untuk menginstal dependensi di Go tanpa panggilan eksplisit.
Seperti banyak bahasa lain, Go memiliki cara untuk secara ajaib mengeksekusi kode tanpa memohonnya secara langsung. Jika Anda membuat fungsi yang disebut init tanpa parameter, itu akan secara otomatis mulai ketika paket dimuat. Dan, untuk lebih membingungkan, jika dalam satu file ada beberapa fungsi dengan nama init atau beberapa file dalam satu paket, semuanya akan mulai.
package account type Account struct{ Id int UserId int } func init() { fmt.Println(" !") } func init() { fmt.Println(" , init()") }
Fungsi init sering dikaitkan dengan impor kosong. Go memiliki cara khusus untuk mendeklarasikan impor, yang terlihat seperti `import _“ github.com / lib / pq`. Ketika Anda menetapkan pengidentifikasi nama kosong untuk paket yang diimpor, metode init berjalan di dalamnya, tetapi tidak menunjukkan pengidentifikasi paket apa pun. Untuk beberapa perpustakaan Go - seperti driver basis data atau format gambar - Anda harus memuatnya dengan mengaktifkan impor paket kosong, hanya untuk memanggil fungsi init sehingga paket dapat mendaftarkan kodenya.
package main import _ "github.com/lib/pq" func main() { db, err := sql.Open( "postgres", "postgres://jon@localhost/evil?sslmode=disable") }
Dan ini jelas merupakan opsi "jahat". Saat Anda menggunakan inisialisasi, kode yang berfungsi secara ajaib benar-benar di luar kendali pengembang. Praktik terbaik tidak merekomendasikan menggunakan fungsi inisialisasi - ini adalah fitur yang tidak jelas, mereka membingungkan kode, dan mereka mudah disembunyikan di perpustakaan.
Dengan kata lain, fungsi init ideal untuk tujuan jahat kita. Alih-alih secara eksplisit mengkonfigurasi atau mendaftarkan entitas dalam paket, Anda dapat menggunakan inisialisasi dan fungsi impor kosong untuk mengonfigurasi status aplikasi Anda. Dalam contoh ini, kami membuat akun tersedia untuk seluruh aplikasi melalui registri, dan paket itu sendiri ditempatkan di registri menggunakan fungsi init.
package account import ( "fmt" "github.com/evil-go/example/registry" ) type StubAccountService struct {} func (a StubAccountService) GetBalance(accountId int) int { return 1000000 } func init() { registry.Register("account", StubAccountService{}) }
Jika Anda ingin menggunakan akun, masukkan impor kosong ke program Anda. Itu tidak harus menjadi kode utama atau terkait - itu hanya harus "suatu tempat." Ini sihir!
package main import ( _ "github.com/evil-go/example/account" "github.com/evil-go/example/registry" ) type Balancer interface { GetBalance(int) int } func main() { a := registry.Get("account").(Balancer) money := a.GetBalance(12345) }
Jika Anda menggunakan init di perpustakaan untuk mengonfigurasi dependensi, Anda akan segera melihat bahwa pengembang lain bingung bagaimana dependensi ini diinstal dan bagaimana mengubahnya. Dan tidak ada yang akan lebih bijaksana daripada Anda.
Konfigurasi yang rumit
Masih banyak hal yang bisa kita lakukan dengan konfigurasi. Jika Anda adalah pengembang Go yang “baik”, Anda ingin mengisolasi konfigurasi dari program lainnya. Dalam fungsi utama (), Anda mendapatkan variabel dari lingkungan dan mengubahnya ke nilai yang diperlukan untuk komponen yang terkait satu sama lain secara eksplisit. Komponen Anda tidak tahu apa-apa tentang file konfigurasi, atau apa sifatnya. Untuk komponen sederhana, Anda mengatur properti publik, dan untuk yang lebih kompleks, Anda dapat membuat fungsi pabrik yang menerima informasi konfigurasi dan mengembalikan komponen yang dikonfigurasi dengan benar.
func main() { b, err := ioutil.ReadFile("account.json") if err != nil { fmt.Errorf("error reading config file: %v", err) os.Exit(1) } m := map[string]interface{}{} json.Unmarshal(b, &m) prefix := m["account.prefix"].(string) maker := account.NewMaker(prefix) } type Maker struct { prefix string } func (m Maker) NewAccount(name string) Account { return Account{Name: name, Id: m.prefix + "-12345"} } func NewMaker(prefix string) Maker { return Maker{prefix: prefix} }
Tetapi pengembang "jahat" tahu bahwa lebih baik menyebarkan informasi tentang konfigurasi di seluruh program. Alih-alih memiliki satu fungsi dalam paket yang mendefinisikan nama dan tipe nilai untuk paket Anda, gunakan fungsi yang mengambil konfigurasi seperti apa adanya dan mengubahnya sendiri.
Jika ini tampaknya terlalu "jahat", gunakan fungsi init untuk memuat file properti dari dalam paket Anda dan atur nilainya sendiri. Tampaknya Anda membuat kehidupan pengembang lain lebih mudah, tetapi Anda dan saya tahu ...
Menggunakan fungsi init, Anda dapat mendefinisikan properti baru di bagian belakang kode, dan tidak ada yang akan menemukannya sampai mereka masuk ke produksi dan semuanya jatuh, karena sesuatu tidak akan masuk ke salah satu dari puluhan file properti yang diperlukan untuk menjalankan. Jika Anda ingin lebih banyak lagi "kekuatan jahat", Anda dapat menyarankan membuat wiki untuk melacak semua properti di semua perpustakaan dan "lupa" secara berkala menambahkan yang baru. Sebagai Penjaga Properti, Anda menjadi satu-satunya orang yang dapat menjalankan perangkat lunak.
func (m maker) NewAccount(name string) Account { return Account{Name: name, Id: m.prefix + "-12345"} } var Maker maker func init() { b, _ := ioutil.ReadFile("account.json") m := map[string]interface{}{} json.Unmarshal(b, &m) Maker.prefix = m["account.prefix"].(string) }
Kerangka kerja fungsionalitas
Akhirnya, kita sampai pada topik kerangka kerja vs perpustakaan. Perbedaannya sangat halus. Ini bukan hanya tentang ukuran; Anda dapat memiliki perpustakaan besar dan kerangka kerja kecil. Kerangka kerja memanggil kode Anda saat Anda memanggil kode perpustakaan sendiri. Kerangka kerja mengharuskan Anda untuk menulis kode dengan cara tertentu, apakah itu penamaan metode Anda sesuai dengan aturan tertentu, atau bahwa mereka sesuai dengan antarmuka tertentu, atau memaksa Anda untuk mendaftarkan kode Anda dalam kerangka kerja. Kerangka kerja memiliki persyaratan sendiri untuk semua kode Anda. Artinya, secara umum, kerangka kerja memerintahkan Anda.
Go mendorong penggunaan perpustakaan karena perpustakaan terhubung. Meskipun, tentu saja, masing-masing perpustakaan mengharapkan data untuk dikirimkan dalam format tertentu, Anda dapat menulis beberapa kode penghubung untuk mengubah output dari satu perpustakaan menjadi input untuk yang lain.
Sulit untuk mendapatkan kerangka kerja untuk bekerja bersama dengan mulus karena setiap kerangka kerja ingin kontrol penuh atas siklus hidup kode. Seringkali satu-satunya cara untuk membuat kerangka kerja untuk bekerja bersama adalah untuk kerangka kerja penulis untuk bersatu dan dengan jelas mengatur saling mendukung. « » — , .
, . «», «» «».