Errorx - pustaka untuk bekerja dengan kesalahan di Go

Apa itu Errorx dan bagaimana itu berguna


Errorx adalah pustaka untuk menangani kesalahan di Go. Ini menyediakan alat untuk memecahkan masalah yang terkait dengan mekanisme kesalahan dalam proyek-proyek besar, dan satu sintaks untuk bekerja dengan mereka.


gambar


Sebagian besar komponen server Joom telah ditulis di Go sejak perusahaan didirikan. Pilihan ini terbayar pada tahap awal pengembangan dan masa pakai layanan, dan mengingat pengumuman tentang prospek Go 2, kami yakin bahwa kami tidak akan menyesalinya di masa mendatang. Salah satu kebajikan utama Go adalah kesederhanaan, dan pendekatan terhadap kesalahan menunjukkan prinsip ini tidak seperti yang lain. Tidak setiap proyek mencapai skala yang memadai sehingga kemampuan perpustakaan standar tidak cukup, mendorong Anda untuk mencari solusi Anda sendiri di bidang ini. Kami kebetulan mengalami beberapa evolusi dalam pendekatan untuk mengatasi kesalahan, dan pustaka errorx mencerminkan hasil dari evolusi ini. Kami yakin itu dapat bermanfaat bagi banyak orang, termasuk mereka yang belum merasa tidak nyaman bekerja dengan kesalahan pada proyek mereka.


Kesalahan dalam Go


Sebelum beralih ke cerita tentang errorx, beberapa klarifikasi harus dibuat. Pada akhirnya, apa yang salah dengan bug?


type error interface { Error() string } 

Sangat sederhana bukan? Dalam praktiknya, suatu implementasi seringkali benar-benar tidak membawa apa-apa selain deskripsi kesalahan. Minimalisme seperti itu dihubungkan dengan pendekatan yang menurutnya kesalahan tidak selalu berarti sesuatu yang "luar biasa". Kesalahan yang paling umum digunakan. Baru () dari perpustakaan standar benar untuk ide ini:


 func New(text string) error { return &errorString{text} } 

Jika kita ingat bahwa kesalahan dalam suatu bahasa tidak memiliki status khusus dan merupakan objek biasa, muncul pertanyaan: apa kekhasan bekerja dengan mereka?


Kesalahan tidak terkecuali . Bukan rahasia lagi bahwa banyak, ketika mereka berkenalan dengan Go, memenuhi perbedaan ini dengan beberapa perlawanan. Ada banyak publikasi, baik yang menjelaskan maupun mendukung, dan mengkritik pendekatan yang dipilih dalam Go. Salah satu cara atau yang lain, kesalahan dalam Go melayani banyak tujuan, dan setidaknya satu di antaranya persis sama dengan pengecualian dalam beberapa bahasa lain: pemecahan masalah. Akibatnya, wajar untuk mengharapkan dari mereka kekuatan ekspresif yang sama, bahkan jika pendekatan dan sintaksis yang terkait dengan penggunaannya sangat berbeda.


Apa yang salah


Banyak proyek memanfaatkan bug di Go, sebagaimana adanya, dan tidak memiliki kesulitan sedikit pun tentang ini. Namun, ketika kompleksitas sistem tumbuh, sejumlah masalah mulai muncul yang menarik perhatian bahkan tanpa adanya harapan yang tinggi. Ilustrasi yang baik adalah baris yang serupa dalam log layanan Anda:


Error: duplicate key


Di sini, masalah pertama langsung menjadi jelas: jika Anda tidak sengaja melakukan hal ini, maka dalam sistem yang besar, hampir tidak mungkin untuk memahami apa yang salah, hanya dengan pesan awal. Posting ini tidak memiliki detail dan konteks masalah yang lebih luas. Ini adalah kesalahan programmer, tetapi terlalu sering mengabaikannya. Kode yang ditujukan untuk cabang "positif" dari grafik kontrol selalu layak mendapat perhatian lebih dalam praktik dan lebih baik dicakup oleh tes daripada kode "negatif" yang terkait dengan gangguan eksekusi atau masalah eksternal. Seberapa sering mantra if err != nil {return err} diulang dalam program Go menjadikan pengawasan ini lebih mungkin.


Sebagai penyimpangan kecil, pertimbangkan contoh ini:


 func (m *Manager) ApplyToUsers(action func(User) (*Data, error), ids []UserID) error { users, err := m.LoadUsers(ids) if err != nil { return err } var actionData []*Data for _, user := range users { data, err := action(user) if err != nil { return err } ok, err := m.validateData(data) if err != nil { return nil } if !ok { log.Error("Validation failed for %v", data) continue } actionData = append(actionData, data) } return m.Apply(actionData) } 

Seberapa cepat Anda melihat kesalahan dalam kode ini? Tapi itu dilakukan setidaknya sekali, mungkin oleh programmer Go. Petunjuk: kesalahan dalam ekspresi if err != nil { return nil } .


Jika kita kembali ke masalah dengan pesan cadel di log, maka dalam situasi ini, tentu saja, semua orang juga terjadi. Mulai memperbaiki kode penanganan kesalahan sudah pada saat masalah terjadi sangat tidak menyenangkan; Selain itu, menurut data awal dari log, tidak jelas pihak mana yang harus mulai mencari bagian kode tersebut, yang, pada kenyataannya, perlu ditingkatkan. Ini mungkin tampak seperti kerumitan yang dibuat-buat untuk proyek-proyek yang kecil dalam kode dan dalam jumlah ketergantungan eksternal. Namun, untuk proyek skala besar ini adalah masalah yang benar-benar nyata dan menyakitkan.


Misalkan pemrogram pengalaman pahit ingin menambahkan konteks terlebih dahulu untuk kesalahan yang kembali. Cara naif untuk melakukan ini adalah sesuatu seperti ini:


 func InsertUser(u *User) error { err := usersTable.Insert(u) if err != nil { return errors.New(fmt.Sprintf("failed to insert user %s: %v", u.Name, err) } return nil } 

Itu menjadi lebih baik. Konteks yang lebih luas masih belum jelas, tetapi sekarang jauh lebih mudah untuk menemukan setidaknya di mana kode kesalahan terjadi. Namun, setelah menyelesaikan satu masalah, kami secara tidak sengaja membuat yang lain. Kesalahan yang dibuat di sini membuat pesan diagnostik tetap asli, tetapi segala sesuatu yang lain, termasuk jenis dan konten tambahannya, hilang.


Untuk melihat mengapa ini berbahaya, pertimbangkan kode serupa di driver database:


 var ErrDuplicateKey = errors.New("duplicate key") func (t *Table) Insert(entity interface{}) error { // returns ErrDuplicateKey if a unique constraint is violated by insert } func IsDuplicateKeyError(err error) bool { return err == ErrDuplicateKey } 

Sekarang pemeriksaan IsDuplicateKeyError() dihancurkan, meskipun pada saat kami menambahkan teks kami ke kesalahan, kami tidak berniat mengubah semantiknya. Ini, pada gilirannya, akan memecah kode yang bergantung pada pemeriksaan ini:


 func RegisterUser(u *User) error { err := InsertUser(u) if db.IsDuplicateKeyError(err) { // find existing user, handle conflict } else { return err } } 

Jika kita ingin melakukan yang lebih cerdas dan menambahkan jenis kesalahan kita sendiri, yang akan menyimpan kesalahan asli dan dapat mengembalikannya, katakanlah, melalui metode Cause() error , maka kita juga akan menyelesaikan masalah hanya sebagian.


  1. Sekarang di tempat pemrosesan kesalahan Anda perlu tahu bahwa alasan sebenarnya terletak pada Cause()
  2. Tidak ada cara untuk mengajarkan perpustakaan eksternal pengetahuan ini, dan fungsi pembantu yang tertulis di dalamnya akan tetap tidak berguna
  3. Implementasi kami dapat mengharapkan Cause() mengembalikan penyebab kesalahan (atau nihil jika tidak), sedangkan implementasi di perpustakaan lain akan mengharapkan metode untuk mengembalikan penyebab non-nil root; kurangnya alat standar atau kontrak yang diterima secara umum mengancam kejutan yang sangat tidak menyenangkan

Namun, solusi parsial ini digunakan di banyak pustaka galat, termasuk, sampai batas tertentu, milik kami. Ada rencana dalam Go 2 untuk mempopulerkan pendekatan ini - jika ini terjadi, akan lebih mudah untuk menangani masalah yang dijelaskan di atas.


Errorx


Di bawah ini kita akan berbicara tentang apa yang ditawarkan errorx, tetapi pertama-tama coba merumuskan pertimbangan yang mendasari perpustakaan.


  • Diagnostik lebih penting daripada menghemat sumber daya. Kinerja membuat dan menampilkan kesalahan penting. Namun demikian, mereka mewakili jalur negatif daripada positif, dan dalam kebanyakan kasus mereka berfungsi sebagai sinyal masalah, oleh karena itu keberadaan informasi diagnostik dalam kesalahan bahkan lebih penting.
  • Tumpukan jejak secara default. Agar kesalahan hilang dengan kepenuhan diagnosis, upaya tidak harus dilakukan. Sebaliknya, justru untuk mengecualikan beberapa informasi (untuk singkatnya atau untuk alasan kinerja) tindakan tambahan mungkin diperlukan.
  • Semantik kesalahan. Seharusnya ada cara yang sederhana dan dapat diandalkan untuk memeriksa arti kesalahan: jenis, ragam, sifat-sifatnya.
  • Kemudahan penambahan. Menambahkan informasi diagnostik ke kesalahan yang lewat harus sederhana, dan tidak boleh merusak verifikasi semantiknya.
  • Kesederhanaan. Kode yang ditujukan untuk kesalahan sering dan secara rutin ditulis, jadi sintaksis manipulasi dasar dengannya harus sederhana dan ringkas. Ini mengurangi jumlah bug dan membuatnya lebih mudah dibaca.
  • Lebih sedikit lebih banyak. Kelengkapan dan keseragaman kode lebih penting daripada fitur opsional dan opsi ekspansi (yang, mungkin, tidak ada yang akan menggunakan).
  • Semantik kesalahan adalah bagian dari API. Kesalahan yang memerlukan pemrosesan terpisah dalam kode panggilan adalah bagian dari paket API publik. Anda tidak perlu mencoba menyembunyikannya atau membuatnya kurang eksplisit, tetapi Anda bisa membuat pemrosesan lebih nyaman, dan ketergantungan eksternal kurang rapuh.
  • Sebagian besar bug bersifat buram. Semakin banyak jenis kesalahan untuk pengguna eksternal tidak dapat dibedakan satu sama lain, semakin baik. Pemuatan jenis kesalahan API yang memerlukan penanganan khusus, serta memuat kesalahan sendiri dengan data yang diperlukan untuk memprosesnya adalah cacat desain yang harus dihindari.

Pertanyaan yang paling sulit bagi kami adalah ekstensibilitas: haruskah errorx memberikan primitif untuk melembagakan jenis kesalahan khusus yang berbeda dalam perilaku, atau adakah implementasi yang memungkinkan Anda untuk mendapatkan semua yang Anda butuhkan di luar kotak? Kami telah memilih opsi kedua. Pertama, errorx memecahkan masalah yang sangat praktis - dan pengalaman kami menggunakannya menunjukkan bahwa untuk tujuan ini lebih baik untuk memiliki solusi, daripada suku cadang untuk membuatnya. Kedua, pertimbangan tentang kesederhanaan sangat signifikan: karena sedikit perhatian diberikan pada kesalahan, kode harus dirancang sedemikian rupa sehingga membuatnya sulit untuk bekerja dengannya. Praktek telah menunjukkan bahwa untuk ini penting bahwa semua kode tersebut terlihat dan berfungsi sama.


TL; DR oleh fitur perpustakaan utama:


  • Tumpuk jejak lokasi pembuatan di semua kesalahan secara default
  • Ketik cek kesalahan, beberapa varietas
  • Kemampuan untuk menambahkan informasi ke kesalahan yang ada tanpa merusak apa pun
  • Ketik kontrol visibilitas jika Anda ingin menyembunyikan alasan asli dari pemanggil
  • Kesalahan dalam menangani mekanisme generalisasi kode (tipe hierarki, sifat)
  • Kustomisasi kesalahan oleh properti dinamis
  • Jenis kesalahan standar
  • Utilitas sintaks untuk meningkatkan keterbacaan kode penanganan kesalahan

Pendahuluan


Jika kita ulang contoh yang kita analisis di atas menggunakan errorx, kita mendapatkan yang berikut:


 var ( DBErrors = errorx.NewNamespace("db") ErrDuplicateKey = DBErrors.NewType("duplicate_key") ) func (t *Table) Insert(entity interface{}) error { // ... return ErrDuplicateKey.New("violated constraint %s", details) } func IsDuplicateKeyError(err error) bool { return errorx.IsOfType(err, ErrDuplicateKey) } 

 func InsertUser(u *User) error { err := usersTable.Insert(u) if err != nil { return errorx.Decorate(err, "failed to insert user %s", u.Name) } return nil } 

Kode pemanggil menggunakan IsDuplicateKeyError() tidak akan berubah.


Apa yang berubah dalam contoh ini?


  • ErrDuplicateKey menjadi tipe, bukan turunan kesalahan; memeriksa apakah itu tahan terhadap kesalahan penyalinan, tidak ada ketergantungan yang rapuh pada kesetaraan yang tepat
  • Ada ruang nama untuk kesalahan basis data; kemungkinan besar akan memiliki kesalahan lain, dan pengelompokan seperti itu berguna untuk keterbacaan dan dalam beberapa kasus dapat digunakan dalam kode
  • Sisipkan mengembalikan kesalahan baru untuk setiap panggilan:
    • Kesalahan tersebut berisi lebih banyak detail; ini, tentu saja, dimungkinkan tanpa errorx, tetapi tidak mungkin jika instance kesalahan yang sama dikembalikan setiap kali, yang sebelumnya diperlukan untuk IsDuplicateKeyError()
    • Kesalahan ini dapat membawa jejak tumpukan yang berbeda, yang berguna karena tidak untuk semua panggilan ke fungsi Sisipkan situasi ini dapat diterima
  • InsertUser() melengkapi teks kesalahan, tetapi menerapkan kesalahan asli, yang dipertahankan secara keseluruhan untuk operasi selanjutnya
  • IsDuplicateKeyError() sekarang berfungsi: ia tidak dapat dimanjakan baik dengan menyalin kesalahan, maupun dengan sebanyak mungkin lapisan yang Anda suka dengan Hiasi ()

Tidak perlu selalu mengikuti skema seperti itu:


  • Jenis kesalahan jauh dari selalu unik: jenis yang sama dapat digunakan di banyak tempat
  • Jika diinginkan, koleksi jejak tumpukan dapat dinonaktifkan, dan Anda tidak dapat membuat kesalahan baru setiap kali, tetapi kembalikan yang sama seperti pada contoh asli; ini disebut kesalahan sentinel, dan kami tidak merekomendasikan penggunaannya, tetapi ini bisa berguna jika kesalahan hanya digunakan sebagai penanda dalam kode, dan Anda ingin menghemat pembuatan objek
  • Ada cara untuk membuat errorx.IsOfType(err, ErrDuplicateKey) berhenti bekerja jika Anda ingin menyembunyikan semantik dari akar masalah dari mata yang mengintip
  • Ada cara lain untuk memeriksa jenis itu sendiri selain membandingkan dengan jenis yang tepat

Godoc berisi informasi terperinci tentang semua ini. Di bawah ini kita akan membahas lebih dalam fitur-fitur utama, yang cukup untuk pekerjaan sehari-hari.


Jenis


Kesalahan errorx termasuk dalam beberapa tipe. Ketik masalah karena properti kesalahan yang diwarisi dapat dilewati; itu melalui dia atau sifat-sifatnya bahwa pengujian semantik akan dilakukan jika perlu. Selain itu, nama ekspresif jenis melengkapi pesan kesalahan dan dalam beberapa kasus mungkin menggantinya.


 AuthErrors = errorx.NewNamespace("auth") ErrInvalidToken = AuthErrors.NewType("invalid_token") 

 return ErrInvalidToken.NewWithNoMessage() 

Pesan kesalahan akan berisi auth.invalid_token . Deklarasi kesalahan mungkin terlihat berbeda:


 ErrInvalidToken = AuthErrors.NewType("invalid_token").ApplyModifiers(errorx.TypeModifierOmitStackTrace) 

Dalam perwujudan ini, menggunakan pengubah tipe, pengumpulan kumpulan jejak dinonaktifkan. Kesalahan memiliki semantik penanda: tipenya diberikan kepada pengguna eksternal layanan, dan tumpukan panggilan dalam log tidak akan berguna, karena ini bukan masalah yang harus diperbaiki.


Di sini kita dapat membuat reservasi bahwa kesalahan memiliki sifat ganda dalam beberapa aspeknya. Isi kesalahan digunakan baik untuk diagnostik dan, kadang-kadang, sebagai informasi untuk pengguna eksternal: klien API, pengguna perpustakaan, dll. Kesalahan digunakan dalam kode baik sebagai sarana untuk menyampaikan semantik tentang apa yang terjadi, dan sebagai mekanisme untuk mentransfer kontrol. Saat menggunakan jenis kesalahan, ini harus diingat.


Pembuatan galat


 return MyType.New("fail") 

Mendapatkan jenis Anda sendiri untuk setiap kesalahan sepenuhnya opsional. Setiap proyek dapat memiliki paket sendiri untuk kesalahan tujuan umum, dan beberapa set dipasok sebagai bagian dari namespace bersama bersama dengan errorx. Ini berisi kesalahan yang dalam banyak kasus tidak melibatkan pemrosesan dalam kode dan cocok untuk situasi "luar biasa" ketika terjadi kesalahan.


 return errorx.IllegalArgument.New("negative value %d", value) 

Dalam kasus tertentu, rantai panggilan dirancang sehingga kesalahan dibuat di bagian paling akhir rantai, dan diproses di awal. Dalam Go, bukannya tanpa alasan dianggap sebagai bentuk buruk untuk memproses kesalahan dua kali, mis., Misalnya, menulis kesalahan ke log dan mengembalikannya lebih tinggi ke tumpukan. Namun, Anda dapat menambahkan informasi ke kesalahan itu sendiri sebelum memberikannya:


 return errorx.Decorate(err, "failed to upload '%s' to '%s'", filename, location) 

Teks yang ditambahkan ke kesalahan akan muncul di log, tetapi tidak ada salahnya untuk memeriksa jenis kesalahan aslinya.


Terkadang muncul kebutuhan yang berlawanan: apa pun sifat kesalahannya, pengguna luar paket tidak boleh mengetahuinya. Jika dia mendapat kesempatan seperti itu, dia bisa membuat ketergantungan yang rapuh pada bagian dari implementasi.


 return service.ErrBadRequest.Wrap(err, "failed to load user data") 

Perbedaan penting yang menjadikan Wrap lebih disukai daripada New adalah kesalahan asli sepenuhnya tercermin dalam log. Dan, khususnya, itu akan membawa setumpuk panggilan awal yang berguna.


Trik lain yang berguna yang memungkinkan Anda untuk menyimpan semua informasi yang mungkin tentang tumpukan panggilan terlihat seperti ini:


 return errorx.EnhanceStackTrace(err, "operation fail") 

Jika kesalahan asli berasal dari goroutine lain, hasil dari panggilan seperti itu akan berisi jejak tumpukan kedua goroutine, yang secara tidak biasa meningkatkan kegunaannya. Kebutuhan untuk melakukan panggilan seperti itu jelas karena masalah kinerja: kasus ini relatif jarang, dan ergonomi yang akan mendeteksi sendiri akan memperlambat Bungkus biasa, di mana tidak diperlukan sama sekali.


Godoc berisi lebih banyak informasi dan juga menjelaskan fitur-fitur tambahan seperti DecorateMany.


Menangani kesalahan


Terbaik jika penanganan kesalahan diturunkan sebagai berikut:


 log.Error("Error: %+v", err) 

Semakin sedikit kesalahan yang perlu Anda buat, kecuali untuk mencetaknya ke log pada lapisan sistem proyek, semakin baik. Pada kenyataannya, ini terkadang tidak cukup, dan Anda harus melakukan ini:


 if errorx.IsOfType(err, MyType) { /* handle */ } 

Pemeriksaan ini akan berhasil pada kesalahan tipe MyType dan pada jenis anaknya, dan tahan terhadap errorx.Decorate() . Namun, di sini ada ketergantungan langsung pada jenis kesalahan, yang cukup normal di dalam paket, tetapi dapat menjadi tidak menyenangkan jika digunakan di luarnya. Dalam beberapa kasus, jenis kesalahan seperti itu adalah bagian dari API eksternal yang stabil, dan kadang-kadang kami ingin mengganti cek ini dengan pemeriksaan properti, dan bukan jenis kesalahan yang tepat.


Dalam kesalahan Go klasik, ini akan dilakukan melalui antarmuka, ketik gips di mana akan berfungsi sebagai indikator dari jenis kesalahan. Jenis Errorx tidak mendukung ekstensi ini, tetapi Anda dapat menggunakan mekanisme Trait sebagai gantinya. Sebagai contoh:


 func IsTemporary(err error) bool { return HasTrait(err, Temporary()) } 

Fungsi ini dibangun ke errorx memeriksa apakah kesalahan memiliki properti standar Temporary , yaitu apakah itu sementara. Menandai jenis kesalahan dengan sifat adalah tanggung jawab sumber kesalahan, dan melaluinya dapat mengirimkan sinyal yang berguna tanpa membuat jenis internal tertentu menjadi bagian dari API eksternal.


 return errorx.IgnoreWithTrait(err, errorx.NotFound()) 

Sintaks ini berguna ketika jenis kesalahan tertentu diperlukan untuk mengganggu aliran kontrol, tetapi tidak boleh diteruskan ke fungsi panggilan.


Terlepas dari banyaknya alat pemrosesan, tidak semuanya terdaftar di sini, penting untuk diingat bahwa penanganan kesalahan harus tetap sesederhana mungkin. Contoh aturan yang kami coba patuhi:


  • Kode yang menerima kesalahan harus selalu mencatatnya secara keseluruhan; jika bagian dari informasi tersebut berlebihan, biarkan kode yang menghasilkan kesalahan mengurus ini
  • Anda tidak boleh menggunakan teks kesalahan atau hasil dari fungsi Error() untuk memprosesnya dalam kode; hanya pemeriksaan jenis / sifat yang cocok untuk ini, atau ketik pernyataan jika terjadi kesalahan non-errorx
  • Kode pengguna tidak boleh rusak karena fakta bahwa beberapa jenis kesalahan tidak diproses dengan cara khusus, bahkan jika pemrosesan tersebut dimungkinkan dan memberikannya fitur tambahan
  • Kesalahan yang diperiksa oleh properti lebih baik daripada yang disebut kesalahan sentinel, karena cek semacam itu kurang rapuh

Di luar errorx


Di sini kami menggambarkan apa yang tersedia untuk pengguna perpustakaan di luar kotak, tetapi di Joom penetrasi kode terkait kesalahan sangat besar. Modul logging secara eksplisit menerima kesalahan dalam tanda tangannya dan mencetak sendiri untuk menghilangkan kemungkinan pemformatan yang salah, serta mengekstrak informasi kontekstual yang tersedia opsional dari rantai kesalahan. Modul yang bertanggung jawab untuk kerja aman panik dengan goroutin membongkar kesalahan jika ia datang dengan panik, dan juga tahu bagaimana menyajikan panik menggunakan sintaks kesalahan tanpa kehilangan jejak tumpukan asli. Beberapa di antaranya, mungkin kami juga akan menerbitkan.


Masalah kompatibilitas


Terlepas dari kenyataan bahwa kami sangat senang dengan bagaimana errorx memungkinkan kami untuk bekerja dengan kesalahan, situasi dengan kode perpustakaan yang dikhususkan untuk topik ini masih jauh dari ideal. Kami di Joom memecahkan masalah praktis yang cukup spesifik dengan errorx, tetapi dari sudut pandang ekosistem Go, akan lebih baik untuk memiliki seluruh rangkaian alat ini di perpustakaan standar. Kesalahan, sumber yang sebenarnya atau berpotensi milik paradigma lain, harus dianggap sebagai alien, yaitu berpotensi tidak membawa informasi dalam bentuk yang diterima dalam proyek.


Namun, beberapa hal telah dilakukan agar tidak bertentangan dengan solusi lain yang ada.


Format '%+v' digunakan untuk mencetak kesalahan bersama dengan jejak tumpukan, jika ada. Ini adalah standar de facto di ekosistem Go dan bahkan termasuk dalam rancangan desain untuk Go 2.


Cause() error errorx , , , Causer, errorx Wrap().



, Go 2, . .


, errorx Go 1. , Go 2, . , , errorx.


Check-handle , errorx , a Unwrap() error Wrap() errorx (.. , , Wrap ), . , , .


design draft Go 2, errorx.Is() errorx.As() , errors .


Kesimpulan


, , , - , . , API : , , . 1.0 , Joom. , - .


: https://github.com/joomcode/errorx


, !


gambar

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


All Articles