Penanganan kesalahan yang lembut di layanan microser

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:


  1. 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
  2. Lapisan aplikasi berisi aturan logika bisnis yang ditentukan oleh tugas aplikasi.
    • aturan: kode di tingkat aplikasi mungkin tergantung pada domain
  3. 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
  4. 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):


Gambar: Go Project Tree


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:


Gambar: Buka Diagram Paket Proyek


Prinsip penanganan kesalahan


Semuanya sederhana di sini:


  1. Kami percaya bahwa kesalahan dan panik terjadi saat memproses permintaan ke API - yang berarti bahwa kesalahan atau panik hanya memengaruhi satu permintaan
  2. 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
  3. 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: //     stacktrace return nil, errors.WithStack(app.ErrNoCake) } } 

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 // No cake is OK, create a new one // ... } else if 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 UnitOfWork

Untuk lebih memahami penggunaan Unit Kerja, lihat diagram:


Image Go Unit Kerja


  • 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 { //     -  commit txErr := u.tx.Commit() *err = errors.Wrap(txErr, "cannot complete transaction") } else { //    -  rollback txErr := return u.tx.Rollback() //  rollback   ,    *err = mergeErrors(*err, errors.Wrap(txErr, "cannot rollback transaction")) } } 

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:


Diagram Paket Pencatat Gambar


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:


Diagram Paket GRPC Gambar


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:


Gambar GRPC Middleware Package Diagram


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:


  1. Panggil metode ListCakes dari objek yang didekorasi
  2. Kami logCall metode logCall , meneruskan semua informasi penting, termasuk satu set bidang yang dipilih secara individual yang termasuk dalam log
  3. 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:


 // ! ! -   err  status.Error func translateError(err error) error { switch errors.Cause(err) { case app.ErrNoCake: err = status.Errorf(codes.NotFound, err.Error()) default: err = status.Errorf(codes.Internal, err.Error()) } return err } 

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.


  1. 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.
  2. 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.
  3. Jangan gunakan perpustakaan logging pihak ketiga di microservice - buat perpustakaan Anda sendiri dengan paket log dan jsonlog, yang akan menyembunyikan detail implementasi logging
  4. 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.

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


All Articles