Artikel tersebut menunjukkan cara menerapkan penanganan kesalahan dan pencatatan berdasarkan prinsip "Dibuat dan Lupa" di Go. Metode ini dirancang untuk layanan microser on Go, bekerja dalam wadah Docker dan dibangun sesuai dengan prinsip-prinsip Arsitektur Bersih.
Artikel ini adalah versi terperinci dari laporan dari pertemuan Go baru-baru ini di Kazan . Jika Anda tertarik dengan Go dan tinggal di Kazan, Innopolis, Yoshkar-Ola yang cantik atau di kota lain terdekat, Anda harus mengunjungi halaman komunitas: golangkazan.imtqy.com .
Pada pertemuan tersebut, tim kami dalam dua laporan menunjukkan bagaimana kami mengembangkan layanan microser on Go - prinsip apa yang kami ikuti dan bagaimana kami menyederhanakan hidup kami. Artikel ini berfokus pada konsep kami tentang penanganan kesalahan, yang sekarang kami sampaikan ke semua layanan Microsoft baru kami.
Perjanjian Struktur Layanan Mikro
Sebelum menyentuh aturan untuk penanganan kesalahan, ada baiknya memutuskan batasan apa yang kami amati saat mendesain dan mengkode. Untuk melakukan ini, ada baiknya memberi tahu seperti apa bentuk layanan microser kami.
Pertama-tama, kami menghormati arsitektur yang bersih. Kami membagi kode menjadi tiga level dan mengamati aturan dependensi: paket pada level yang lebih dalam tidak tergantung pada paket eksternal dan tidak ada dependensi siklik. Untungnya, dependensi round-robin langsung dari paket dilarang di Go. Ketergantungan tidak langsung melalui terminologi peminjaman, asumsi tentang perilaku atau casting ke suatu tipe masih dapat muncul, mereka harus dihindari.
Beginilah level kami terlihat:
- Level domain berisi aturan logika bisnis yang ditentukan oleh area subjek.
- terkadang kita melakukannya tanpa domain jika tugasnya sederhana
- aturan: kode di tingkat domain hanya tergantung pada kemampuan Go, perpustakaan Go standar dan perpustakaan yang dipilih yang memperluas bahasa Go
- Lapisan aplikasi berisi aturan logika bisnis yang ditentukan oleh tugas aplikasi.
- aturan: kode di tingkat aplikasi mungkin tergantung pada domain
- Level infrastruktur berisi kode infrastruktur yang menghubungkan aplikasi dengan berbagai teknologi untuk penyimpanan (MySQL, Redis), transportasi (GRPC, HTTP), interaksi dengan lingkungan eksternal dan dengan layanan lainnya
- aturan: kode di tingkat infrastruktur mungkin tergantung pada domain dan aplikasi
- aturan: hanya satu teknologi per paket Go
- Paket utama membuat semua objek - "singleton seumur hidup", menghubungkan mereka bersama dan meluncurkan coroutine berumur panjang - misalnya, ia mulai memproses permintaan HTTP dari port 8081
Ini adalah bagaimana struktur direktori microservice terlihat (bagian di mana kode Go):

Untuk setiap konteks aplikasi (modul), struktur paket terlihat seperti ini:
- paket aplikasi mendeklarasikan antarmuka Layanan yang berisi semua tindakan yang mungkin dilakukan pada tingkat tertentu yang mengimplementasikan antarmuka struktur layanan dan fungsi
func NewService(...) Service
- isolasi pekerjaan dengan database dicapai karena kenyataan bahwa domain atau paket aplikasi mendeklarasikan antarmuka Repositori, yang diimplementasikan pada tingkat infrastruktur dalam paket dengan nama visual "mysql"
- kode transportasi terletak di paket
infrastructure/transport
- kami menggunakan GRPC, sehingga stubs server dihasilkan dari file proto (mis. antarmuka server, struktur Respons / Permintaan dan semua kode interaksi klien)
Semua ini ditunjukkan dalam diagram:

Prinsip penanganan kesalahan
Semuanya sederhana di sini:
- Kami percaya bahwa kesalahan dan panik terjadi saat memproses permintaan ke API - yang berarti bahwa kesalahan atau panik hanya memengaruhi satu permintaan
- Kami percaya bahwa log hanya diperlukan untuk analisis insiden (dan ada debugger untuk debugging), oleh karena itu, informasi tentang permintaan diterima dalam log, dan, pertama-tama, kesalahan tak terduga saat memproses permintaan
- Kami percaya bahwa seluruh infrastruktur dibangun untuk memproses log (misalnya, berdasarkan pada ELK) - dan layanan mikro memainkan peran pasif di dalamnya, menulis log ke stderr
Kami tidak akan fokus pada kepanikan: jangan lupa untuk menangani kepanikan di setiap goroutine dan selama pemrosesan setiap permintaan, setiap pesan, setiap tugas asinkron yang diluncurkan oleh permintaan. Hampir selalu panik bisa berubah menjadi kesalahan untuk mencegah seluruh aplikasi selesai.
Kesalahan Idiom Sentinel
Pada tingkat logika bisnis, hanya kesalahan yang diharapkan yang ditentukan oleh aturan bisnis yang diproses. Sentinel Errors akan membantu Anda mengidentifikasi kesalahan seperti itu - kami menggunakan idiom ini alih-alih menulis tipe data kami sendiri untuk kesalahan. Contoh:
package app import "errors" var ErrNoCake = errors.New("no cake found")
Variabel global dideklarasikan di sini, yang, atas persetujuan tuan kita, kita tidak boleh berubah di mana pun. Jika Anda tidak menyukai variabel global dan menggunakan linter untuk mendeteksinya, maka Anda dapat bertahan dengan beberapa konstanta, seperti yang disarankan Dave Cheney di pos kesalahan Konstan :
package app type Error string func (e Error) Error() string { return string(e) } const ErrNoCake = Error("no cake found")
Jika Anda menyukai pendekatan ini, Anda mungkin ingin menambahkan jenis ConstError
ke pustaka bahasa Go perusahaan Anda.
Komposisi kesalahan
Keuntungan utama Sentinel Errors adalah kemampuan untuk membuat kesalahan dengan mudah. Secara khusus, ketika membuat kesalahan atau menerima kesalahan dari luar, alangkah baiknya menambahkan stacktrace ke dalamnya. Untuk tujuan tersebut, ada dua solusi populer.
- paket xerrors, yang dalam Go 1.13 akan dimasukkan dalam perpustakaan standar sebagai percobaan
- paket github.com/pkg/errors oleh Dave Cheney
- paket tersebut dibekukan dan tidak berkembang, tetapi tetap saja bagus
Tim kami masih menggunakan github.com/pkg/errors
dan errors.WithStack
Fungsi errors.WithStack
(ketika kami tidak memiliki apa-apa untuk ditambahkan, kecuali stacktrace) atau errors.Wrap
(ketika kami memiliki sesuatu untuk dikatakan tentang kesalahan ini). Kedua fungsi menerima kesalahan pada input dan mengembalikan kesalahan baru, tetapi dengan stacktrace. Contoh dari lapisan infrastruktur:
package mysql import "github.com/pkg/errors" func (r *repository) FindOne(...) { row := r.client.QueryRow(sql, params...) switch err := row.Scan(...) { case sql.ErrNoRows:
Kami menyarankan agar setiap kesalahan hanya dibungkus satu kali. Ini mudah dilakukan jika Anda mengikuti aturan:
- setiap kesalahan eksternal dibungkus satu kali dalam salah satu paket infrastruktur
- setiap kesalahan yang dihasilkan oleh aturan logika bisnis dilengkapi dengan stacktrace pada saat pembuatan
Akar penyebab kesalahan
Semua kesalahan diharapkan dibagi menjadi yang diharapkan dan yang tidak terduga. Untuk menangani kesalahan yang diharapkan, Anda harus menyingkirkan efek komposisi. Paket xerrors dan github.com/pkg/errors
memiliki semua yang Anda butuhkan: khususnya, paket kesalahan memiliki errors.Cause
Fungsi penyebab, yang mengembalikan akar penyebab kesalahan. Fungsi ini dalam satu lingkaran, satu demi satu, mengambil kesalahan sebelumnya sementara kesalahan diekstraksi berikutnya memiliki metode Cause() error
.
Contoh yang kami ekstrak akar penyebabnya dan langsung membandingkannya dengan kesalahan sentinel:
func (s *service) SaveCake(...) error { state, err := s.repo.FindOne(...) if errors.Cause(err) == ErrNoCake { err = nil
Kesalahan dalam menangani penundaan
Mungkin Anda menggunakan linter, yang membuat Anda memeriksa semua kesalahan secara manual. Dalam hal ini, Anda mungkin sangat marah ketika linter meminta Anda untuk memeriksa kesalahan dengan metode .Close()
dan metode lain yang hanya Anda defer
. Pernahkah Anda mencoba menangani kesalahan dengan benar dalam penundaan, terutama jika ada kesalahan lain sebelumnya? Dan kami telah mencoba dan sedang terburu-buru untuk berbagi resep.
Bayangkan kita memiliki semua pekerjaan dengan database secara ketat melalui transaksi. Menurut aturan dependensi, level aplikasi dan domain tidak boleh secara langsung atau tidak langsung bergantung pada infrastruktur dan teknologi SQL. Ini berarti bahwa pada tingkat aplikasi dan domain tidak ada kata "transaksi" .
Solusi paling sederhana adalah mengganti kata "transaksi" dengan sesuatu yang abstrak; dengan demikian pola Unit Kerja lahir. Dalam implementasi kami, layanan dalam paket aplikasi menerima pabrik melalui antarmuka UnitOfWorkFactory, dan selama setiap operasi membuat objek UnitOfWork yang menyembunyikan transaksi. Objek UnitOfWork memungkinkan Anda untuk mendapatkan Repositori.
Lebih lanjut tentang UnitOfWorkUntuk lebih memahami penggunaan Unit Kerja, lihat diagram:

- Repositori merupakan kumpulan objek abstrak yang persisten (misalnya, agregat level domain) dari tipe yang ditentukan
- UnitOfWork menyembunyikan transaksi dan membuat objek Repositori
- UnitOfWorkFactory hanya memungkinkan layanan untuk membuat transaksi baru tanpa mengetahui apa pun tentang transaksi.
Apakah tidak berlebihan untuk membuat transaksi untuk setiap operasi, bahkan awalnya atom? Terserah Anda; Kami percaya bahwa menjaga independensi logika bisnis lebih penting daripada menghemat menciptakan transaksi.
Apakah mungkin untuk menggabungkan UnitOfWork dan Repositori? Itu mungkin, tetapi kami percaya bahwa ini melanggar prinsip Tanggung Jawab Tunggal.
Seperti inilah tampilannya:
type UnitOfWork interface { Repository() Repository Complete(err *error) }
Antarmuka UnitOfWork menyediakan metode Lengkap, yang mengambil satu parameter masuk: pointer ke antarmuka kesalahan. Ya, itu adalah penunjuk, dan itu adalah parameter masuk-keluar - dalam kasus lain, kode di sisi panggilan akan jauh lebih rumit.
Contoh operasi dengan unitOfWork:
Perhatian: kesalahan harus dinyatakan sebagai nilai balik yang dinamai. Jika alih-alih nilai balik bernama err Anda menggunakan variabel lokal err, maka Anda tidak dapat menggunakannya dalam penundaan! Dan belum ada satupun linter yang akan mendeteksi ini - lihat go-critic # 801
func (s *service) CookCake() (err error) { unitOfWork, err := s.unitOfWorkFactory.New() if err != nil { return err } defer unitOfWork.Complete(&err) repo := unitOfWork.Repository() }
Jadi penyelesaiannya terealisasi transaksi UnitOfWork:
func (u *unitOfWork) Complete(err *error) { if *err == nil {
Fungsi mergeErrors
menggabungkan dua kesalahan, tetapi memproses nol tanpa masalah, bukan satu atau kedua kesalahan. Pada saat yang sama, kami percaya bahwa kedua kesalahan terjadi selama pelaksanaan satu operasi pada tahapan yang berbeda, dan kesalahan pertama lebih penting - oleh karena itu, ketika kedua kesalahan tidak nol, kami menyimpan yang pertama, dan dari kesalahan kedua kami hanya menyimpan pesan:
package errors func mergeErrors(err error, nextErr error) error { if err == nil { err = nextErr } else if nextErr != nil { err = errors.Wrap(err, nextErr.Error()) } return err }
Mungkin Anda harus menambahkan fungsi mergeErrors
ke pustaka perusahaan Anda untuk Go.
Subsistem logging
Daftar Periksa Artikel : apa yang harus Anda lakukan sebelum memulai layanan-layanan mikro dalam saran menyarankan:
- log ditulis dalam stderr
- log harus dalam JSON, satu objek JSON ringkas per baris
- Seharusnya ada seperangkat bidang standar:
- timestamp - waktu acara dalam milidetik , lebih disukai dalam format RFC 3339 (contoh: "1985-04-12T23: 20: 50.52Z")
- level - level kepentingan, misalnya, "info" atau "kesalahan"
- app_name - nama aplikasi
- dan bidang lainnya
Kami lebih suka menambahkan dua bidang lagi ke pesan kesalahan: "error"
dan "stacktrace"
.
Ada banyak perpustakaan pencatatan kualitas untuk bahasa Golang, misalnya, sirupsen / logrus , yang kami gunakan. Tapi kami tidak menggunakan perpustakaan secara langsung. Pertama-tama, dalam paket log
kami, kami mengurangi antarmuka pustaka yang terlalu luas menjadi satu antarmuka Logger:
package log type Logger interface { WithField(string, interface{}) Logger WithFields(Fields) Logger Debug(...interface{}) Info(...interface{}) Error(error, ...interface{}) }
Jika programmer ingin menulis log, ia harus mendapatkan antarmuka Logger dari luar, dan ini harus dilakukan di tingkat infrastruktur, bukan aplikasi atau domain. Antarmuka logger ringkas:
- itu mengurangi jumlah tingkat keparahan untuk debug, info dan kesalahan, seperti yang disarankan artikel ini. Mari kita bicara tentang pencatatan.
- itu memperkenalkan aturan khusus untuk metode Kesalahan: metode selalu menerima objek kesalahan
Kekakuan ini memungkinkan kita untuk mengarahkan pemrogram ke arah yang benar: jika seseorang ingin melakukan perbaikan dalam sistem logging itu sendiri, ia harus melakukannya dengan mempertimbangkan seluruh infrastruktur pengumpulan dan pemrosesan mereka, yang hanya dimulai pada layanan mikro (dan biasanya berakhir di suatu tempat di Kibana dan Zabbix).
Namun, dalam paket log ada antarmuka lain yang memungkinkan Anda untuk menghentikan program ketika kesalahan fatal terjadi dan karena itu hanya dapat digunakan dalam paket utama:
package log type MainLogger interface { Logger FatalError(error, ...interface{}) }
Paket jsonlog
Menerapkan antarmuka Logger paket jsonlog
kami, yang mengonfigurasi pustaka logrus dan mengabstraksi pekerjaan dengannya. Secara skematis terlihat seperti ini:

Paket berpemilik memungkinkan Anda untuk menghubungkan kebutuhan layanan Microsoft (diungkapkan oleh antarmuka log.Logger
), kemampuan perpustakaan logrus, dan fitur infrastruktur Anda, pencatatan.
Sebagai contoh, kami menggunakan ELK (Pencarian Elastis, Logstash, Kibana), dan karenanya dalam paket jsonlog kami:
- setel format
logrus.JSONFormatter
untuk logrus.JSONFormatter
- pada saat yang sama, kami mengatur opsi FieldMap, yang dengannya kami mengubah bidang
"time"
menjadi "@timestamp"
, dan bidang "msg"
menjadi "message"
- pilih level log
- tambahkan hook yang mengekstrak stacktrace dari objek
Error(error, ...interface{})
diteruskan ke metode Error(error, ...interface{})
Layanan Microsoft menginisialisasi logger di fungsi utama:
func initLogger(config Config) (log.MainLogger, error) { logLevel, err := jsonlog.ParseLevel(config.LogLevel) if err != nil { return nil, errors.Wrap(err, "failed to parse log level") } return jsonlog.NewLogger(&jsonlog.Config{ Level: logLevel, AppName: "cookingservice" }), nil }
Penanganan Kesalahan dan Penebangan dengan Middleware
Kami beralih ke GRPC di layanan microser kami di Go. Tetapi bahkan jika Anda menggunakan HTTP API, prinsip-prinsip umum adalah untuk Anda.
Pertama-tama, penanganan kesalahan dan penebangan harus terjadi pada tingkat infrastructure
dalam paket yang bertanggung jawab untuk transportasi, karena dialah yang menggabungkan pengetahuan tentang aturan protokol transportasi dan pengetahuan tentang app.Service
. app.Service
antarmuka layanan. Ingat seperti apa hubungan paket itu:

Lebih mudah untuk memproses kesalahan dan memelihara log menggunakan pola Middleware (Middleware adalah nama pola Penghias di dunia Golang dan Node.js):
Di mana menambahkan Middleware? Berapa banyak yang seharusnya ada?
Ada beberapa opsi untuk menambahkan Middleware, Anda pilih:
- Anda dapat menghias
app.Service
Antarmuka layanan, tetapi kami tidak menyarankan melakukan ini karena antarmuka ini tidak menerima informasi lapisan transport, seperti IP klien - Dengan GRPC Anda dapat menggantung satu penangan pada semua permintaan (lebih tepatnya, dua - unary dan steam), tetapi kemudian semua metode API akan dicatat dalam gaya yang sama dengan kumpulan bidang yang sama
- Dengan GRPC, pembuat kode membuatkan kami antarmuka server tempat kami memanggil
app.Service
Metode layanan - kami menghias antarmuka ini karena memiliki informasi tingkat transportasi dan kemampuan untuk mencatat berbagai metode API dengan cara yang berbeda.
Secara skematis terlihat seperti ini:

Anda dapat membuat berbagai Middlewares untuk penanganan kesalahan (dan panik) dan untuk pencatatan. Anda bisa menyilangkan semuanya menjadi satu. Kami akan mempertimbangkan contoh di mana semuanya disilangkan menjadi satu Middleware, yang dibuat seperti ini:
func NewMiddleware(next api.BackendService, logger log.Logger) api.BackendService { server := &errorHandlingMiddleware{ next: next, logger: logger, } return server }
Kami mendapatkan antarmuka api.BackendService
sebagai api.BackendService
dan menghiasnya, mengembalikan implementasi antarmuka api.BackendService
sebagai api.BackendService
.
Metode API sewenang-wenang di Middleware diimplementasikan sebagai berikut:
func (m *errorHandlingMiddleware) ListCakes( ctx context.Context, req *api.ListCakesRequest) (*api.ListCakesResponse, error) { start := time.Now() res, err := m.next.ListCakes(ctx, req) m.logCall(start, err, "ListCakes", log.Fields{ "cookIDs": req.CookIDs, }) return res, translateError(err) }
Di sini kami melakukan tiga tugas:
- Panggil metode ListCakes dari objek yang didekorasi
- Kami
logCall
metode logCall
, meneruskan semua informasi penting, termasuk satu set bidang yang dipilih secara individual yang termasuk dalam log - Pada akhirnya, kami mengganti kesalahan dengan memanggil translateError.
Terjemahan kesalahan akan dibahas nanti. Dan logCall
dilakukan dengan metode logCall
, yang hanya memanggil metode antarmuka Logger yang benar:
func (m *errorHandlingMiddleware) logCall(start time.Time, err error, method string, fields log.Fields) { fields["duration"] = fmt.Sprintf("%v", time.Since(start)) fields["method"] = method logger := m.logger.WithFields(fields) if err != nil { logger.Error(err, "call failed") } else { logger.Info("call finished") } }
Terjemahan kesalahan
Kami harus mendapatkan akar penyebab kesalahan dan mengubahnya menjadi kesalahan yang dapat dipahami di tingkat transportasi dan didokumentasikan dalam API layanan Anda.
Di GRPC, sangat sederhana - gunakan fungsi status.Errorf
untuk membuat kesalahan dengan kode status. Jika Anda memiliki HTTP API (REST API), Anda dapat membuat jenis kesalahan sendiri yang seharusnya tidak diketahui oleh tingkat aplikasi dan domain.
Pada perkiraan pertama, terjemahan kesalahannya terlihat seperti ini:
Saat memvalidasi argumen input, antarmuka yang didekorasi dapat mengembalikan kesalahan status.Status
ketik dengan kode status, dan versi pertama dari translateError akan kehilangan kode status ini.
Mari kita buat versi yang disempurnakan dengan melakukan casting ke jenis antarmuka (ketikan bebek hidup panjang!):
type statusError interface { GRPCStatus() *status.Status } func isGrpcStatusError(er error) bool { _, ok := err.(statusError) return ok } func translateError(err error) error { if isGrpcStatusError(err) { return err } switch errors.Cause(err) { case app.ErrNoCake: err = status.Errorf(codes.NotFound, err.Error()) default: err = status.Errorf(codes.Internal, err.Error()) } return err }
Fungsi translateError
dibuat secara individual untuk setiap konteks (modul independen) di layanan mikro Anda dan menerjemahkan kesalahan logika bisnis menjadi kesalahan tingkat transportasi.
Untuk meringkas
Kami menawarkan beberapa aturan untuk menangani kesalahan dan bekerja dengan log. Apakah mengikuti atau tidak itu terserah Anda.
- Ikuti prinsip Arsitektur Bersih, jangan langsung atau tidak langsung melanggar aturan dependensi. Logika bisnis seharusnya hanya bergantung pada bahasa pemrograman, dan bukan pada teknologi eksternal.
- Gunakan paket yang menawarkan komposisi kesalahan dan pembuatan stacktrace. Misalnya, "github.com/pkg/errors" atau paket xerrors, yang akan segera menjadi bagian dari perpustakaan standar Go.
- Jangan gunakan perpustakaan logging pihak ketiga di microservice - buat perpustakaan Anda sendiri dengan paket log dan jsonlog, yang akan menyembunyikan detail implementasi logging
- Gunakan pola Middleware untuk menangani kesalahan dan menulis log pada arah pengangkutan tingkat infrastruktur program
Di sini kami tidak mengatakan apa pun tentang teknologi penelusuran kueri (misalnya, OpenTracing), pemantauan metrik (misalnya, kinerja kueri basis data) dan hal-hal lain seperti pencatatan. Anda sendiri yang akan berurusan dengan ini, kami percaya pada Anda.