Teknik mengembangkan server yang sangat andal di Go

Dari waktu ke waktu, programmer web menghadapi tugas yang bahkan dapat menakuti para profesional. Kita berbicara tentang mengembangkan aplikasi server yang tidak memiliki hak untuk melakukan kesalahan, tentang proyek-proyek di mana biaya kegagalan sangat tinggi. Penulis materi, terjemahan yang kami terbitkan hari ini, akan berbicara tentang bagaimana mendekati tugas-tugas tersebut.



Tingkat keandalan apa yang dibutuhkan proyek Anda?


Sebelum mempelajari rincian pengembangan aplikasi server yang sangat andal, Anda harus bertanya pada diri sendiri apakah proyek Anda benar-benar membutuhkan tingkat keandalan tertinggi yang dapat dicapai. Proses pengembangan sistem yang dirancang untuk skenario kerja di mana kesalahannya mirip dengan bencana universal dapat menjadi sangat rumit untuk sebagian besar proyek di mana konsekuensi dari kemungkinan kesalahan tidak terlalu menakutkan.

Jika biaya kesalahan tidak menjadi sangat tinggi, suatu pendekatan dapat diterima, di mana pengembang melakukan upaya yang paling masuk akal untuk memastikan pengoperasian proyek, dan jika masalah muncul, ia hanya memahaminya. Alat pemantauan modern dan proses penyebaran perangkat lunak berkelanjutan memungkinkan Anda mengidentifikasi masalah produksi dengan cepat dan memperbaikinya hampir secara instan. Dalam banyak kasus, ini sudah cukup.

Dalam proyek yang saya kerjakan hari ini, ini tidak begitu. Kita berbicara tentang implementasi blockchain - infrastruktur server terdistribusi untuk eksekusi kode yang aman di lingkungan dengan tingkat kepercayaan rendah, sambil mencapai konsensus. Salah satu aplikasi teknologi ini adalah mata uang digital. Ini adalah contoh klasik dari sistem dengan biaya kesalahan yang sangat tinggi. Dalam hal ini, pengembang proyek benar-benar perlu membuatnya sangat, sangat andal.

Namun, dalam beberapa proyek lain, bahkan jika mereka tidak terkait dengan keuangan, pengejaran keandalan kode tertinggi masuk akal. Biaya pemeliharaan basis kode yang sering rusak dapat dengan cepat mencapai nilai-nilai astronomi. Kemampuan untuk mengidentifikasi masalah pada tahap awal proses pengembangan, ketika biaya untuk memperbaikinya masih rendah, terlihat seperti imbalan yang sangat nyata untuk investasi waktu dan upaya tepat waktu ke dalam metodologi pengembangan sistem yang sangat andal.

Mungkin solusinya adalah TDD?


Pengembangan melalui pengujian ( Test Driven Development , TDD) sering dianggap obat terbaik untuk kode buruk. TDD adalah metodologi pengembangan murni, dalam penerapan tes yang ditulis pertama, dan hanya kemudian - kode yang ditambahkan ke proyek hanya ketika tes yang memeriksanya, berhenti menghasilkan kesalahan. Proses ini menjamin cakupan 100% dari kode dengan tes dan sering memberikan ilusi bahwa kode diuji dalam semua varian yang mungkin dari penggunaannya.

Namun, ini tidak benar. TDD adalah metodologi hebat yang bekerja dengan baik di beberapa area, tetapi untuk mengembangkan kode yang benar-benar andal, tidak cukup. Lebih buruk lagi, TDD menginspirasi pengembang dengan kepercayaan yang salah dan penerapan metodologi ini dapat mengarah pada fakta bahwa ia tidak akan, karena malas, menulis tes untuk memeriksa kegagalan sistem dalam situasi yang kemunculannya, dari sudut pandang akal sehat, hampir mustahil. Kami akan membicarakan ini nanti.

Tes adalah kunci keandalan


Sebenarnya, tidak masalah apakah Anda membuat tes sebelum menulis kode, atau setelahnya, apakah Anda menggunakan metodologi pengembangan seperti TDD, atau tidak. Yang utama adalah fakta menjalani tes. Pengujian adalah benteng pertahanan terbaik yang melindungi kode Anda dari masalah produksi.

Karena kita akan menjalankan tes kita sangat sering, idealnya setelah menambahkan setiap baris baru ke kode, tes tersebut perlu dilakukan secara otomatis. Kepercayaan kami pada kualitas kode tidak boleh didasarkan pada pemeriksaan manualnya. Masalahnya, orang cenderung membuat kesalahan. Perhatian seseorang terhadap detail melemah setelah dia menyelesaikan tugas yang sama berkali-kali berturut-turut.

Tes harus cepat. Sangat cepat.

Jika diperlukan lebih dari beberapa detik untuk menyelesaikan rangkaian uji, pengembang kemungkinan besar akan malas dan menambahkan kode ke proyek tanpa mengujinya. Kecepatan adalah salah satu kekuatan terbesar Go. Toolkit pengembangan dalam bahasa ini adalah salah satu yang tercepat di antara yang ada. Menyusun, membangun kembali, dan menguji proyek dilakukan dalam hitungan detik.

Tes, sebagai tambahan, adalah salah satu kekuatan pendorong penting dari proyek open source. Misalnya, ini berlaku untuk semua yang terkait dengan teknologi blockchain. Sumber terbuka di sini hampir merupakan sebuah agama. Basis kode untuk mendapatkan kepercayaan pada mereka yang akan menggunakannya harus terbuka. Ini memungkinkan, misalnya, untuk melakukan auditnya, ia menciptakan suasana desentralisasi, di mana tidak ada entitas tertentu yang mengendalikan proyek.

Tidak masuk akal untuk menunggu kontribusi yang signifikan terhadap proyek open source dari pengembang eksternal jika proyek ini tidak termasuk tes kualitas. Peserta proyek eksternal membutuhkan mekanisme untuk dengan cepat memeriksa kompatibilitas dari apa yang mereka tulis dengan apa yang sudah ditambahkan ke proyek. Seluruh rangkaian tes, pada kenyataannya, harus dilakukan secara otomatis setelah menerima setiap permintaan untuk menambahkan kode baru ke proyek. Jika sesuatu yang seharusnya ditambahkan ke proyek melalui permintaan seperti itu merusakkan sesuatu, tes harus segera melaporkannya.

Cakupan penuh basis kode dengan tes adalah metrik yang menipu tetapi penting. Tujuan mencapai cakupan kode 100% dengan tes mungkin tampak berlebihan, tetapi jika Anda memikirkannya, ternyata jika kode tersebut tidak sepenuhnya dicakup oleh tes, beberapa bagian dari kode dikirim ke produksi tanpa verifikasi, yang belum pernah dilakukan sebelumnya.

Cakupan penuh kode dengan tes tidak selalu berarti bahwa ada cukup tes dalam proyek, dan tidak berarti bahwa ini adalah tes yang benar-benar memberikan semua opsi untuk menggunakan kode. Dengan percaya diri, kami hanya dapat mengatakan bahwa jika proyek tidak 100% dicakup dalam pengujian, pengembang tidak dapat memastikan keandalan mutlak kode, karena beberapa bagian dari kode tidak pernah diuji.

Meskipun demikian, ada situasi di mana ada terlalu banyak tes. Idealnya, setiap kesalahan yang mungkin harus mengarah pada kegagalan satu tes. Jika jumlah tes berlebihan, yaitu, tes yang berbeda memeriksa fragmen kode yang sama, kemudian memodifikasi kode yang ada dan mengubah perilaku sistem yang ada akan mengarah pada fakta bahwa agar tes yang ada sesuai dengan kode baru, akan terlalu banyak waktu untuk memprosesnya .

Mengapa Go adalah pilihan tepat untuk proyek yang sangat andal?


Go adalah bahasa yang diketik statis. Jenis adalah kontrak antara berbagai bagian kode yang dieksekusi bersama. Tanpa pengecekan tipe otomatis selama proses perakitan proyek, jika Anda harus mematuhi aturan ketat untuk mencakup kode dengan pengujian, kami harus menerapkan tes yang memverifikasi "kontrak" ini dengan kami sendiri. Ini, misalnya, terjadi di proyek server dan klien berdasarkan JavaScript. Menulis tes kompleks yang hanya ditujukan untuk memeriksa jenis berarti banyak pekerjaan tambahan, yang, dalam kasus Go, dapat dihindari.

Go adalah bahasa yang sederhana dan dogmatis. Seperti yang Anda ketahui, Go menyertakan banyak ide tradisional untuk bahasa pemrograman, seperti warisan OOP klasik. Kompleksitas adalah musuh terburuk dari kode yang dapat diandalkan. Masalah cenderung bersembunyi di sendi struktur yang kompleks. Ini dinyatakan dalam kenyataan bahwa meskipun opsi tipikal untuk menggunakan desain tertentu mudah untuk diuji, ada kasus perbatasan aneh yang mungkin tidak dipikirkan oleh pengembang tes. Proyek ini, pada akhirnya, akan menurunkan hanya satu dari kasus-kasus ini. Dalam pengertian ini, dogmatisme juga berguna. Di Go, seringkali hanya ada satu cara untuk melakukan suatu tindakan. Ini mungkin tampak seperti faktor yang menahan semangat bebas programmer, tetapi ketika sesuatu dapat dilakukan hanya dengan satu cara, sulit untuk melakukan sesuatu yang salah.

Go singkat tapi ekspresif. Kode yang mudah dibaca lebih mudah untuk dianalisis dan diaudit. Jika kode terlalu verbose, tujuan utamanya mungkin tenggelam dalam "noise" konstruksi tambahan. Jika kodenya terlalu singkat, program-program di atasnya mungkin sulit dibaca dan dipahami. Go menjaga keseimbangan antara keringkasan dan ekspresi. Misalnya, tidak ada banyak konstruksi bantu di dalamnya, seperti dalam bahasa seperti Java atau C ++. Pada saat yang sama, konstruksi Go, yang berkaitan, misalnya, dengan bidang-bidang seperti penanganan kesalahan, sangat jelas dan cukup rinci, yang menyederhanakan pekerjaan programmer, membantunya memastikan, misalnya, bahwa ia telah memeriksa semua yang mungkin.

Go memiliki mekanisme penanganan kesalahan dan pemulihan yang jelas setelah crash. Mekanisme penanganan kesalahan runtime yang baik adalah landasan kode yang sangat andal. Go memiliki aturan ketat untuk mengembalikan dan mendistribusikan kesalahan. Dalam lingkungan seperti Node.js, mencampuradukkan pendekatan untuk mengendalikan aliran program, seperti panggilan balik, janji, dan fungsi asinkron, sering menyebabkan kesalahan yang tidak tertangani, seperti penolakan janji yang tidak tertangani . Memulihkan program setelah kejadian serupa hampir tidak mungkin .

Go memiliki perpustakaan standar yang luas. Ketergantungan adalah risiko, terutama ketika sumbernya adalah proyek di mana perhatian yang cukup diberikan untuk keandalan kode. Aplikasi server yang masuk ke produksi berisi semua dependensi. Selain itu, jika terjadi kesalahan, pengembang aplikasi yang sudah selesai akan bertanggung jawab untuk ini, dan bukan orang yang membuat salah satu perpustakaan yang digunakan olehnya. Akibatnya, dalam lingkungan di mana proyek yang ditulisnya kewalahan dengan ketergantungan kecil, lebih sulit untuk membuat aplikasi yang andal.

Ketergantungan juga merupakan risiko keamanan, karena tingkat kerentanan proyek sesuai dengan tingkat kerentanan ketergantungannya yang paling tidak aman . Pustaka standar Go yang luas dikelola oleh pengembangnya dalam kondisi sangat baik, keberadaannya mengurangi kebutuhan akan ketergantungan eksternal.

Kecepatan pengembangan tinggi. Fitur utama dari lingkungan seperti Node.js adalah siklus pengembangannya yang sangat singkat. Menulis kode membutuhkan waktu lebih sedikit, sebagai akibatnya, programmer menjadi lebih produktif.

Go juga memiliki kecepatan pengembangan yang tinggi. Seperangkat alat untuk membangun proyek cukup cepat untuk dapat langsung melihat kode dalam tindakan. Waktu kompilasi sangat singkat, sebagai hasilnya, menjalankan kode on Go dianggap seolah-olah tidak dikompilasi, tetapi ditafsirkan. Pada saat yang sama, bahasa tersebut memiliki abstraksi yang cukup, seperti sistem pengumpulan sampah, yang memungkinkan pengembang untuk mengarahkan upaya untuk mengimplementasikan fungsionalitas proyek mereka, dan tidak menyelesaikan tugas tambahan.

Eksperimen praktis


Sekarang kita telah menyuarakan poin-poin umum yang cukup, saatnya untuk melihat kode. Kita membutuhkan contoh yang cukup sederhana sehingga, ketika mempelajarinya, kita dapat fokus pada metodologi pengembangan, tetapi pada saat yang sama, itu harus cukup maju sehingga kita, ketika menjelajahi itu, memiliki sesuatu untuk dibicarakan. Saya memutuskan bahwa akan lebih mudah untuk mengambil sesuatu dari apa yang saya lakukan setiap hari. Oleh karena itu, saya mengusulkan untuk mengurai penciptaan server yang memproses sesuatu yang menyerupai transaksi keuangan. Pengguna server ini akan dapat memeriksa saldo akun yang terkait dengan akun mereka. Selain itu, mereka akan dapat mentransfer dana dari satu akun ke akun lainnya.

Kami akan berusaha untuk tidak menyulitkan contoh ini. Sistem kami akan memiliki satu server. Kami tidak akan menghubungi sistem otentikasi dan kriptografi. Ini adalah bagian integral dari proyek kerja. Tetapi kita perlu fokus pada inti dari proyek semacam itu, untuk menunjukkan bagaimana membuatnya seandal mungkin.

Ivid Membagi proyek kompleks menjadi bagian-bagian yang nyaman untuk dikelola


Kompleksitas adalah musuh terburuk keandalan. Salah satu pendekatan terbaik ketika bekerja dengan sistem yang kompleks adalah dengan menerapkan prinsip lama "membagi dan menaklukkan." Tugas ini perlu dibagi menjadi beberapa subtugas kecil dan menyelesaikannya secara terpisah. Sisi mana yang mendekati partisi tugas kita? Kami akan mengikuti prinsip tanggung jawab bersama . Setiap bagian dari proyek kami harus memiliki bidang tanggung jawabnya sendiri.

Ide ini sangat cocok dengan arsitektur microservice yang populer. Server kami akan terdiri dari layanan terpisah. Setiap layanan akan memiliki area tanggung jawab yang jelas dan antarmuka yang dijelaskan dengan jelas untuk berinteraksi dengan layanan lain.

Setelah kami menyusun server dengan cara ini, kami akan dapat membuat keputusan tentang bagaimana masing-masing layanan harus bekerja. Semua layanan dapat dilakukan bersama, dalam proses yang sama, dari masing-masing Anda dapat membuat server terpisah dan membangun interaksinya menggunakan RPC, Anda dapat memisahkan layanan dan menjalankannya di komputer yang berbeda.

Kami tidak akan mempersulit tugas, kami akan memilih opsi yang paling sederhana. Yaitu, semua layanan akan dieksekusi dalam proses yang sama, mereka akan bertukar informasi secara langsung, seperti perpustakaan. Jika perlu, di masa depan solusi arsitektur ini dapat dengan mudah ditinjau dan diubah.

Jadi layanan apa yang kita butuhkan? Server kami mungkin terlalu sederhana untuk membaginya menjadi beberapa bagian, tetapi, untuk tujuan pendidikan, kami, bagaimanapun, akan membaginya. Kita perlu menanggapi permintaan HTTP klien yang ditujukan untuk memeriksa saldo dan mengeksekusi transaksi. Salah satu layanan dapat bekerja dengan antarmuka HTTP untuk klien. PublicApi saja PublicApi . Layanan lain akan memiliki informasi tentang keadaan sistem - neraca. StateStorage saja StateStorage . Layanan ketiga akan menggabungkan keduanya yang dijelaskan di atas dan menerapkan logika "kontrak" yang ditujukan untuk mengubah saldo. Tugas layanan ketiga adalah pelaksanaan kontrak. VirtualMachine saja VirtualMachine .


Arsitektur Server Aplikasi

Tempatkan kode layanan ini dalam folder proyek /services/publicapi , /services/virtualmachine dan /services/statestorage .

▍ Definisi tanggung jawab layanan yang jelas


Selama implementasi layanan, kami ingin dapat bekerja dengan masing-masing secara individual. Bahkan dimungkinkan untuk membagi pengembangan layanan ini antara programmer yang berbeda. Karena layanan saling bergantung, dan kami ingin memparalelkan pengembangannya, kami harus mulai bekerja dengan definisi antarmuka yang jelas yang mereka gunakan untuk berinteraksi satu sama lain. Dengan menggunakan antarmuka ini, kita dapat menguji layanan secara mandiri dengan menyiapkan stubs untuk semua yang ada di luar masing-masing.

Bagaimana cara mendeskripsikan antarmuka? Salah satu opsi adalah untuk mendokumentasikan semuanya, tetapi dokumentasi memiliki properti menjadi usang, dalam proses mengerjakan suatu proyek, perbedaan mulai menumpuk antara dokumentasi dan kode. Selain itu, kita dapat menggunakan deklarasi antarmuka Go. Ini adalah opsi yang menarik, tetapi lebih baik untuk mendeskripsikan antarmuka sehingga deskripsi ini tidak tergantung pada bahasa pemrograman tertentu. Ini akan berguna bagi kita dalam situasi yang sangat nyata, jika dalam proses mengerjakan suatu proyek akan diputuskan untuk mengimplementasikan beberapa layanannya dalam bahasa lain, kemampuan yang lebih cocok untuk memecahkan masalah mereka.

Salah satu opsi untuk menggambarkan antarmuka adalah dengan menggunakan protobuf . Ini adalah protokol independen bahasa dan bahasa yang sederhana untuk menggambarkan pesan dan titik akhir layanan.

Mari kita mulai dengan antarmuka untuk layanan StateStorage . Kami akan menyajikan keadaan aplikasi dalam bentuk struktur tampilan nilai kunci. Berikut adalah kode untuk file statestorage.proto :

 syntax = "proto3"; package statestorage; service StateStorage { rpc WriteKey (WriteKeyInput) returns (WriteKeyOutput); rpc ReadKey (ReadKeyInput) returns (ReadKeyOutput); } message WriteKeyInput { string key = 1; int32 value = 2; } message WriteKeyOutput { } message ReadKeyInput { string key = 1; } message ReadKeyOutput { int32 value = 1; } 

Meskipun klien menggunakan HTTP melalui layanan PublicApi , ia juga tidak mengganggu antarmuka yang jelas yang dijelaskan dengan cara yang sama seperti di atas (file publicapi.proto ):

 syntax = "proto3"; package publicapi; import "protocol/transactions.proto"; service PublicApi { rpc Transfer (TransferInput) returns (TransferOutput); rpc GetBalance (GetBalanceInput) returns (GetBalanceOutput); } message TransferInput { protocol.Transaction transaction = 1; } message TransferOutput { string success = 1; int32 result = 2; } message GetBalanceInput { protocol.Address from = 1; } message GetBalanceOutput { string success = 1; int32 result = 2; } 

Sekarang kita perlu menggambarkan struktur data Transaction dan Address (file transactions.proto ):

 syntax = "proto3"; package protocol; message Address { string username = 1; } message Transaction { Address from = 1; Address to = 2; int32 amount = 3; } 

Dalam proyek tersebut, deskripsi proto untuk layanan ditempatkan di folder /types/services , dan deskripsi struktur data tujuan umum ada di folder /types/protocol .

Setelah deskripsi antarmuka siap, mereka dapat dikompilasi ke dalam kode Go.

Kelebihan dari pendekatan ini adalah kode yang tidak cocok dengan deskripsi antarmuka tidak muncul dalam hasil kompilasi. Menggunakan metode alternatif akan mengharuskan kita untuk menulis tes khusus untuk memverifikasi bahwa kode tersebut cocok dengan deskripsi antarmuka.

Definisi lengkap, file Go yang dihasilkan, dan instruksi kompilasi dapat ditemukan di sini . Ini dimungkinkan berkat Square Engineering dan pengembangan goprotowrap mereka .

Harap dicatat bahwa dalam proyek kami layer transport RPC tidak diimplementasikan dan pertukaran data antara layanan tampak seperti panggilan perpustakaan biasa. Ketika kami siap untuk mendistribusikan layanan di server yang berbeda, kami dapat menambahkan lapisan transport seperti gRPC ke sistem.

▍ Jenis tes yang digunakan dalam proyek


Karena tes adalah kunci untuk kode yang sangat andal, saya menyarankan agar kita pertama-tama berbicara tentang tes mana yang akan kita tulis untuk proyek kita.

Tes unit


Tes unit adalah inti dari piramida pengujian . Kami akan menguji setiap modul secara terpisah. Apa itu modul? Di Go, kita dapat melihat modul sebagai file terpisah dalam sebuah paket. Sebagai contoh, jika kita memiliki file /services/publicapi/handlers.go , maka kita akan menempatkan tes unit untuk itu dalam paket yang sama di /services/publicapi/handlers_test.go .

Cara terbaik adalah menempatkan tes unit dalam paket yang sama dengan kode tes, yang memungkinkan tes memiliki akses ke variabel dan fungsi yang tidak diekspor.

Tes Layanan


Jenis tes berikut dikenal dengan berbagai nama. Ini adalah layanan yang disebut, tes integrasi atau komponen. Esensi mereka adalah mengambil beberapa modul dan menguji kerja bersama mereka. Tes-tes ini satu tingkat lebih tinggi dari tes unit dalam piramida tes. Dalam kasus kami, kami akan menggunakan tes integrasi untuk menguji seluruh layanan. Tes-tes ini menentukan spesifikasi untuk layanan. Misalnya, tes untuk layanan StateStorage akan ditempatkan di folder /services/statestorage/spec .

Cara terbaik untuk menempatkan tes ini dalam paket yang berbeda dari yang di mana kode yang diuji terletak sehingga akses ke kemampuan kode ini dilakukan hanya melalui antarmuka yang diekspor.

Tes ujung ke ujung


Tes-tes ini berada di puncak piramida pengujian, dengan bantuan mereka memeriksa keseluruhan sistem dan semua layanannya dilakukan. Tes tersebut menggambarkan spesifikasi end-to-end, e2e untuk sistem, jadi kami akan menempatkannya di folder /e2e/spec .

Tes ujung ke ujung, serta tes layanan, harus ditempatkan dalam paket yang berbeda dari yang di mana kode yang diuji terletak sehingga sistem hanya dapat dioperasikan melalui antarmuka yang diekspor.

Tes apa yang harus ditulis terlebih dahulu? Mulailah dengan dasar "piramida" dan naik? Atau mulai dari atas dan turun? Salah satu dari pendekatan ini memiliki hak untuk hidup. Manfaat dari pendekatan top-down adalah menciptakan spesifikasi terlebih dahulu untuk seluruh sistem. Biasanya paling mudah untuk membahas di awal pekerjaan tentang fitur-fitur sistem secara keseluruhan. Bahkan jika kita membagi sistem ke dalam layanan terpisah secara tidak benar, spesifikasi sistem akan tetap tidak berubah. Ini, sebagai tambahan, akan membantu kita memahami bahwa sesuatu, pada level yang lebih rendah, dilakukan secara tidak benar.

Kelemahan dari pendekatan top-down adalah bahwa tes end-to-end adalah tes yang digunakan setelah semua yang lain, ketika seluruh sistem yang dikembangkan dibuat. Ini berarti bahwa mereka akan menghasilkan kesalahan untuk waktu yang lama. Saat menulis tes untuk proyek kami, kami akan menggunakan pendekatan ini.

DevelopmentTes pengembangan


Pengembangan tes ujung ke ujung


Sebelum membuat tes, kita perlu memutuskan apakah kita akan menulisnya tanpa menggunakan alat bantu apa pun atau menggunakan semacam kerangka kerja. Mengandalkan kerangka kerja, menggunakannya sebagai ketergantungan pengembangan, kurang berbahaya daripada mengandalkan kerangka kerja dalam kode yang masuk ke produksi. Dalam kasus kami, karena perpustakaan Go standar tidak memiliki dukungan BDD yang layak, dan format ini sangat bagus untuk menggambarkan spesifikasi, kami akan memilih opsi kerja yang mencakup penggunaan kerangka kerja.

Ada banyak kerangka kerja hebat yang memberikan apa yang kita butuhkan. Di antara mereka adalah GoConvey dan Ginkgo .

Secara pribadi, saya suka menggunakan kombinasi Ginkgo dan Gomega (nama-nama yang mengerikan, tetapi apa yang harus dilakukan) yang menggunakan konstruksi sintaksis seperti Describe() dan It() .

Seperti apa pengujian kami? Misalnya, berikut ini adalah tes untuk mekanisme pemeriksaan saldo pengguna (file sanity.go ):

 package spec import ... var _ = Describe("Sanity", func() { var ( node services.Node ) BeforeEach(func() { node = services.NewNode() node.Start() }) AfterEach(func() { node.Stop() }) It("should show balances with GET /api/balance", func() { resp, err := http.Get("http://localhost:8080/api/balance?from=user1") Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal("0")) }) }) 

Karena server dapat diakses dari dunia luar melalui HTTP, kami akan bekerja dengan API webnya menggunakan http.Get . Bagaimana dengan pengujian transaksional? Berikut adalah kode untuk tes yang sesuai:

 It("should transfer funds with POST /api/transfer", func() { resp, err := http.Get("http://localhost:8080/api/transfer?from=user1&to=user2&amount=17") Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal("-17")) resp, err = http.Post("http://localhost:8080/api/balance?from=user2", "text/plain", nil) Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal("17")) }) 

Kode tes dengan sempurna menggambarkan esensi mereka, bahkan dapat menggantikan dokumentasi. Seperti yang Anda lihat, kami mengakui adanya saldo akun pengguna negatif. Ini adalah fitur dari proyek kami. Jika dilarang, keputusan ini akan tercermin dalam tes.

Ini adalah kode tes lengkap

Pengembangan Tes Layanan


Sekarang, setelah mengembangkan tes end-to-end, kami pergi ke piramida pengujian dan melanjutkan untuk membuat tes layanan. Tes semacam itu dikembangkan untuk setiap layanan individual. Kami memilih layanan yang memiliki ketergantungan pada layanan lain, karena kasus ini lebih menarik daripada mengembangkan tes untuk layanan independen.

Mari kita mulai dengan layanan VirtualMachine . Di sini Anda dapat menemukan antarmuka dengan proto-deskripsi untuk layanan ini. Karena layanan VirtualMachine bergantung pada layanan StateStorage dan mengaksesnya, kita perlu membuat objek tiruan untuk layanan StateStorage untuk menguji layanan VirtualMachine secara terpisah. Objek rintisan memungkinkan kita untuk mengontrol respons StateStorage selama pengujian.

Bagaimana cara mengimplementasikan objek rintisan di Go? Ini dapat dilakukan secara eksklusif melalui bahasa, tanpa alat bantu, atau Anda dapat menggunakan perpustakaan yang sesuai, yang, di samping itu, akan memungkinkan untuk bekerja dengan pernyataan dalam proses pengujian. Untuk tujuan ini, saya lebih suka menggunakan perpustakaan go-mock .

Kami akan menempatkan kode rintisan di file /services/statestorage/mock.go . Cara terbaik adalah menempatkan objek rintisan di tempat yang sama dengan entitas yang ditirunya untuk memberi mereka akses ke variabel dan fungsi yang tidak diekspor. Rintisan pada tahap ini adalah implementasi skematis dari layanan, tetapi, ketika layanan berkembang, kita mungkin perlu mengembangkan implementasi rintisan. Berikut adalah kode untuk objek stub (file mock.go ):

 package statestorage import ... type MockService struct { mock.Mock } func (s *MockService) Start() { s.Called() } func (s *MockService) Stop() { s.Called() } func (s *MockService) IsStarted() bool { return s.Called().Bool(0) } func (s *MockService) WriteKey(input *statestorage.WriteKeyInput) (*statestorage.WriteKeyOutput, error) { ret := s.Called(input) return ret.Get(0).(*statestorage.WriteKeyOutput), ret.Error(1) } func (s *MockService) ReadKey(input *statestorage.ReadKeyInput) (*statestorage.ReadKeyOutput, error) { ret := s.Called(input) return ret.Get(0).(*statestorage.ReadKeyOutput), ret.Error(1) } 

Jika Anda memberikan pengembangan layanan individual ke pemrogram yang berbeda, masuk akal untuk membuat rintisan terlebih dahulu dan meneruskannya ke tim.

Mari kita kembali mengembangkan tes layanan untuk VirtualMachine . Skenario apa yang harus saya periksa di sini? Yang terbaik adalah fokus pada antarmuka layanan dan tes desain untuk setiap titik akhir. Kami menerapkan tes untuk titik akhir CallContract() dengan argumen yang mewakili metode "GetBalance" . Berikut adalah kode yang sesuai (file contracts.go ):

 package spec import ... var _ = Describe("Contracts", func() { var ( service uut.Service stateStorage *_statestorage.MockService ) BeforeEach(func() { service = uut.NewService() stateStorage = &_statestorage.MockService{} service.Start(stateStorage) }) AfterEach(func() { service.Stop() }) It("should support 'GetBalance' contract method", func() { stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key: "user1"}).Return(&statestorage.ReadKeyOutput{Value: 100}, nil).Times(1) addr := protocol.Address{Username: "user1"} out, err := service.CallContract(&virtualmachine.CallContractInput{Method: "GetBalance", Arg: &addr}) Expect(err).ToNot(HaveOccurred()) Expect(out.Result).To(BeEquivalentTo(100)) Expect(stateStorage).To(ExecuteAsPlanned()) }) }) 

Harap perhatikan bahwa layanan yang kami uji, VirtualMachine , mendapat petunjuk untuk ketergantungannya, StateStorage , dalam metode Start() melalui mekanisme injeksi ketergantungan sederhana. Di sinilah kita melewatkan turunan objek stub. Juga, perhatikan status stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key… , di mana kita memberi tahu objek stub bagaimana seharusnya berperilaku ketika mengaksesnya. Ketika metode ReadKey , itu harus mengembalikan nilai 100. Kemudian, di baris Expect(stateStorage).To(ExecuteAsPlanned()) , kami memeriksa bahwa perintah ini dipanggil tepat sekali.

Tes serupa menjadi spesifikasi untuk layanan ini. Set lengkap pengujian untuk layanan VirtualMachine dapat ditemukan di sini . Suite tes untuk layanan lain dari proyek kami dapat ditemukan di sini dan di sini .

Pengembangan Tes Unit


Mungkin implementasi kontrak untuk metode "GetBalance" terlalu sederhana, jadi "GetBalance" bicara tentang menerapkan metode "Transfer" sedikit lebih kompleks. Kontrak untuk mentransfer dana dari satu akun ke akun lain yang diwakili oleh metode ini perlu membaca data tentang saldo pengirim dan penerima dana, untuk menghitung saldo baru dan untuk mencatat apa yang terjadi dalam keadaan aplikasi. Tes layanan untuk semua ini sangat mirip dengan yang baru saja kami terapkan (file transactions.go ):

 It("should support 'Transfer' transaction method", func() { stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key: "user1"}).Return(&statestorage.ReadKeyOutput{Value: 100}, nil).Times(1) stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key: "user2"}).Return(&statestorage.ReadKeyOutput{Value: 50}, nil).Times(1) stateStorage.When("WriteKey", &statestorage.WriteKeyInput{Key: "user1", Value: 90}).Return(&statestorage.WriteKeyOutput{}, nil).Times(1) stateStorage.When("WriteKey", &statestorage.WriteKeyInput{Key: "user2", Value: 60}).Return(&statestorage.WriteKeyOutput{}, nil).Times(1) t := protocol.Transaction{From: &protocol.Address{Username: "user1"}, To: &protocol.Address{Username: "user2"}, Amount: 10} out, err := service.ProcessTransaction(&virtualmachine.ProcessTransactionInput{Method: "Transfer", Arg: &t}) Expect(err).ToNot(HaveOccurred()) Expect(out.Result).To(BeEquivalentTo(90)) Expect(stateStorage).To(ExecuteAsPlanned()) }) 

Dalam proses mengerjakan proyek, kami akhirnya bisa membuat mekanisme internal dan membuat modul yang terletak di file processor.go , yang berisi implementasi kontrak. Ini adalah versi aslinya (file processor.go ):

 package virtualmachine import ... func (s *service) processTransfer(fromUsername string, toUsername string, amount int32) (int32, error) { fromBalance, err := s.stateStorage.ReadKey(&statestorage.ReadKeyInput{Key: fromUsername}) if err != nil { return 0, err } toBalance, err := s.stateStorage.ReadKey(&statestorage.ReadKeyInput{Key: toUsername}) if err != nil { return 0, err } _, err = s.stateStorage.WriteKey(&statestorage.WriteKeyInput{Key: fromUsername, Value: fromBalance.Value - amount}) if err != nil { return 0, err } _, err = s.stateStorage.WriteKey(&statestorage.WriteKeyInput{Key: toUsername, Value: toBalance.Value + amount}) if err != nil { return 0, err } return fromBalance.Value - amount, nil } 

Desain ini memenuhi uji layanan, tetapi dalam kasus kami, uji integrasi hanya berisi tes skenario dasar. Bagaimana dengan kasus batas dan potensi kegagalan? Seperti yang Anda lihat, semua panggilan yang kami lakukan untuk StateStorage mungkin gagal. Jika cakupan 100% dari kode dengan tes diperlukan, kita perlu memeriksa semua situasi ini. Tes unit sangat bagus untuk menerapkan tes semacam itu.

Karena kita akan memanggil fungsi beberapa kali dengan data input yang berbeda dan mensimulasikan parameter untuk mencapai semua cabang kode, untuk membuat proses ini lebih efisien, kita dapat menggunakan tes berbasis tabel. Go cenderung menghindari kerangka uji unit eksotis. Kita bisa menolak Ginkgo , tetapi mungkin kita harus meninggalkan Gomega . Akibatnya, pemeriksaan yang dilakukan di sini akan sama dengan yang kami lakukan di tes sebelumnya. Berikut adalah kode tes (file processor_test.go ):

 package virtualmachine import ... var transferTable = []struct{ to string //  ,    read1Err error //       read2Err error //       write1Err error //       write2Err error //       output int32 //   errs bool //        }{ {"user2", errors.New("a"), nil, nil, nil, 0, true}, {"user2", nil, errors.New("a"), nil, nil, 0, true}, {"user2", nil, nil, errors.New("a"), nil, 0, true}, {"user2", nil, nil, nil, errors.New("a"), 0, true}, {"user2", nil, nil, nil, nil, 90, false}, } func TestTransfer(t *testing.T) { Ω := NewGomegaWithT(t) for _, tt := range transferTable { s := NewService() ss := &_statestorage.MockService{} s.Start(ss) ss.When("ReadKey", &statestorage.ReadKeyInput{Key: "user1"}).Return(&statestorage.ReadKeyOutput{Value: 100}, tt.read1Err) ss.When("ReadKey", &statestorage.ReadKeyInput{Key: "user2"}).Return(&statestorage.ReadKeyOutput{Value: 50}, tt.read2Err) ss.When("WriteKey", &statestorage.WriteKeyInput{Key: "user1", Value: 90}).Return(&statestorage.WriteKeyOutput{}, tt.write1Err) ss.When("WriteKey", &statestorage.WriteKeyInput{Key: "user2", Value: 60}).Return(&statestorage.WriteKeyOutput{}, tt.write2Err) output, err := s.(*service).processTransfer("user1", tt.to, 10) if tt.errs { Ω.Expect(err).To(HaveOccurred()) } else { Ω.Expect(err).ToNot(HaveOccurred()) Ω.Expect(output).To(BeEquivalentTo(tt.output)) } } } 

«Ω» — , — ( Gomega ). .

, TDD, , , . processTransfer() .

VirtualMachine . .

100% . , . .

, ? . , , , .

▍ -


. ? HTTP- Go (goroutine). , — , . , , , .

- . , , , , . - /e2e/stress . - ( stress.go ):

 package stress import ... const NUM_TRANSACTIONS = 20000 const NUM_USERS = 100 const TRANSACTIONS_PER_BATCH = 200 const BATCHES_PER_SEC = 40 var _ = Describe("Transaction Stress Test", func() { var ( node services.Node ) BeforeEach(func() { node = services.NewNode() node.Start() }) AfterEach(func() { node.Stop() }) It("should handle lots and lots of transactions", func() { //  HTTP-     transport := http.Transport{ IdleConnTimeout: time.Second*20, MaxIdleConns: TRANSACTIONS_PER_BATCH*10, MaxIdleConnsPerHost: TRANSACTIONS_PER_BATCH*10, } client := &http.Client{Transport: &transport} //      ledger := map[string]int32{} for i := 0; i < NUM_USERS; i++ { ledger[fmt.Sprintf("user%d", i+1)] = 0 } //     HTTP   rand.Seed(42) done := make(chan error, TRANSACTIONS_PER_BATCH) for i := 0; i < NUM_TRANSACTIONS / TRANSACTIONS_PER_BATCH; i++ { log.Printf("Sending %d transactions... (batch %d out of %d)", TRANSACTIONS_PER_BATCH, i+1, NUM_TRANSACTIONS / TRANSACTIONS_PER_BATCH) time.Sleep(time.Second / BATCHES_PER_SEC) for j := 0; j < TRANSACTIONS_PER_BATCH; j++ { from := randomizeUser() to := randomizeUser() amount := randomizeAmount() ledger[from] -= amount ledger[to] += amount go sendTransaction(client, from, to, amount, &done) } for j := 0; j < TRANSACTIONS_PER_BATCH; j++ { err := <- done Expect(err).ToNot(HaveOccurred()) } } //   for i := 0; i < NUM_USERS; i++ { user := fmt.Sprintf("user%d", i+1) resp, err := client.Get(fmt.Sprintf("http://localhost:8080/api/balance?from=%s", user)) Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal(fmt.Sprintf("%d", ledger[user]))) } }) }) func randomizeUser() string { return fmt.Sprintf("user%d", rand.Intn(NUM_USERS)+1) } func randomizeAmount() int32 { return rand.Int31n(1000)+1 } func sendTransaction(client *http.Client, from string, to string, amount int32, done *chan error) { url := fmt.Sprintf("http://localhost:8080/api/transfer?from=%s&to=%s&amount=%d", from, to, amount) resp, err := client.Post(url, "text/plain", nil) if err == nil { ioutil.ReadAll(resp.Body) resp.Body.Close() } *done <- err } 

, - . ( rand.Seed(42) ) , . . , , — , .

- HTTP , TCP- ( , , ). , , 200 IdleConnection TCP- . , 100.

… :

 fatal error: concurrent map writes goroutine 539 [running]: runtime.throw(0x147bf60, 0x15) /usr/local/go/src/runtime/panic.go:616 +0x81 fp=0xc4207159d8 sp=0xc4207159b8 pc=0x102ca01 runtime.mapassign_faststr(0x13f5140, 0xc4201ca0c0, 0xc4203a8097, 0x6, 0x1012001) /usr/local/go/src/runtime/hashmap_fast.go:703 +0x3e9 fp=0xc420715a48 sp=0xc4207159d8 pc=0x100d879 services/statestorage.(*service).WriteKey(0xc42000c060, 0xc4209e6800, 0xc4206491a0, 0x0, 0x0) services/statestorage/methods.go:15 +0x10c fp=0xc420715a88 sp=0xc420715a48 pc=0x138339c services/virtualmachine.(*service).processTransfer(0xc4201ca090, 0xc4203a8097, 0x6, 0xc4203a80a1, 0x6, 0x2a4, 0xc420715b30, 0x1012928, 0x40) services/virtualmachine/processor.go:19 +0x16e fp=0xc420715ad0 sp=0xc420715a88 pc=0x13840ee services/virtualmachine.(*service).ProcessTransaction(0xc4201ca090, 0xc4209e67c0, 0x30, 0x1433660, 0x12a1d01) Ginkgo ran 1 suite in 1.288879763s Test Suite Failed 

? StateStorage ( map ), . , , . , map sync.map . .

processTransfer() . , — . , , , , . , processTransfer() . .

, . , , .

 e2e/stress/transactions.go:44 Expected <string>: -7498 to equal <string>: -7551 e2e/stress/transactions.go:82 ------------------------------ Ginkgo ran 1 suite in 5.251593179s Test Suite Failed 

, . , , ( , ). , , .

— . TDD . ? , 100%?! , — . processTransfer() , , .

. , , . .

Ringkasan


, , , -, , , ? ? — .

, -. , «» processTransfer() . , , . , — . , - . , , .

. , . , StateStorage WriteKey , , , , WriteKeys , , .

, : . « ». -, , , , , . — . , , — .

, — GitHub. . , , , , , , .

Pembaca yang budiman! ?

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


All Articles