Antarmuka Penghasil Klien Berbasis Database Golang

Basis data klien golang berdasarkan antarmuka.



Untuk bekerja dengan database, Golang menawarkan paket database/sql , yang merupakan abstraksi dari antarmuka pemrograman basis data relasional. Di satu sisi, paket termasuk fungsionalitas yang kuat untuk mengelola kumpulan koneksi, bekerja dengan pernyataan yang disiapkan, transaksi, dan antarmuka kueri basis data. Di sisi lain, Anda harus menulis sejumlah besar jenis kode yang sama dalam aplikasi web untuk berinteraksi dengan database. Pustaka go-gad / sal menawarkan solusi dalam bentuk menghasilkan jenis kode yang sama berdasarkan antarmuka yang dijelaskan.


Motivasi


Saat ini, ada cukup banyak perpustakaan yang menawarkan solusi dalam bentuk ORM, pembantu untuk membangun kueri, menghasilkan pembantu berdasarkan skema database.



Ketika saya beralih ke bahasa Golang beberapa tahun yang lalu, saya sudah memiliki pengalaman bekerja dengan database dalam berbagai bahasa. Menggunakan ORM, seperti ActiveRecord, dan tanpa. Setelah beralih dari cinta menjadi benci, tidak memiliki masalah menulis beberapa baris kode tambahan, berinteraksi dengan basis data di Golang menghasilkan sesuatu seperti pola penyimpanan. Kami menggambarkan antarmuka untuk bekerja dengan database, kami menerapkannya menggunakan standar db.Query, row.Scan. Untuk menggunakan pembungkus tambahan sama sekali tidak masuk akal, itu buram, itu akan memaksa untuk waspada.


Bahasa SQL itu sendiri sudah merupakan abstraksi antara program Anda dan data dalam repositori. Tampaknya selalu tidak masuk akal bagi saya untuk mencoba menggambarkan skema data, dan kemudian membangun kueri yang kompleks. Struktur respons dalam kasus ini berbeda dari skema data. Ternyata kontrak perlu dijelaskan bukan pada tingkat skema data, tetapi pada tingkat permintaan dan tanggapan. Kami menggunakan pendekatan ini dalam pengembangan web ketika kami menggambarkan struktur data permintaan dan tanggapan API. Saat mengakses layanan menggunakan RESTful JSON atau gRPC, kami mendeklarasikan kontrak pada tingkat permintaan dan respons menggunakan Skema atau Protobuf JSON, dan bukan skema data entitas dalam layanan.


Artinya, berinteraksi dengan database datang ke metode yang serupa:


 type User struct { ID int64 Name string } type Store interface { FindUser(id int64) (*User, error) } type Postgres struct { DB *sql.DB } func (pg *Postgres) FindUser(id int64) (*User, error) { var resp User err := pg.DB.QueryRow("SELECT id, name FROM users WHERE id=$1", id).Scan(&resp.ID, &resp.Name) if err != nil { return nil, err } return &resp, nil } func HanlderFindUser(s Store, id int) (*User, error) { // logic of service object user, err := s.FindUser(id) //... } 

Dengan cara ini, program Anda dapat diprediksi. Tapi jujur ​​saja, ini bukan mimpi penyair. Kami ingin mengurangi jumlah kode boilerplate untuk membuat kueri, mengisi struktur data, menggunakan pengikatan variabel, dan sebagainya. Saya mencoba merumuskan daftar persyaratan yang harus dipenuhi oleh seperangkat utilitas yang diinginkan.


Persyaratan


  • Deskripsi interaksi dalam bentuk antarmuka.
  • Antarmuka dijelaskan oleh metode dan pesan permintaan dan tanggapan.
  • Dukungan untuk variabel yang mengikat dan pernyataan yang disiapkan.
  • Dukungan untuk argumen yang disebutkan.
  • Menautkan respons basis data ke bidang struktur data pesan.
  • Dukungan untuk struktur data atipikal (array, json).
  • Pekerjaan transparan dengan transaksi.
  • Dukungan asli untuk middleware.

Kami ingin mengabstraksi implementasi interaksi dengan database menggunakan antarmuka. Ini akan memungkinkan kami untuk menerapkan sesuatu yang mirip dengan pola desain seperti repositori. Pada contoh di atas, kami menggambarkan antarmuka Store. Sekarang kita bisa menggunakannya sebagai ketergantungan. Pada tahap pengujian, kita dapat melewatkan tulisan rintisan yang dihasilkan berdasarkan antarmuka ini, dan dalam produk kita akan menggunakan implementasi kita berdasarkan pada struktur Postgres.


Setiap metode antarmuka menjelaskan satu permintaan basis data. Parameter input dan output metode harus menjadi bagian dari kontrak untuk permintaan tersebut. String kueri harus dapat memformat tergantung pada parameter input. Ini terutama benar ketika menyusun kueri dengan kondisi pengambilan sampel yang kompleks.


Saat menyusun kueri, kami ingin menggunakan subtitusi dan pengikatan variabel. Sebagai contoh, di PostgreSQL, Anda menulis $1 alih-alih nilai, dan bersama-sama dengan kueri, berikan array argumen. Argumen pertama akan digunakan sebagai nilai dalam kueri yang dikonversi. Dukungan untuk ekspresi yang disiapkan memungkinkan Anda untuk tidak khawatir mengatur penyimpanan ekspresi yang sama ini. Pangkalan data / sql menyediakan alat yang ampuh untuk mendukung ekspresi yang disiapkan, itu sendiri menangani kumpulan koneksi, koneksi tertutup. Tetapi pada bagian pengguna, tindakan tambahan diperlukan untuk menggunakan kembali ekspresi yang disiapkan dalam transaksi.


Basis data, seperti PostgreSQL dan MySQL, menggunakan sintaks yang berbeda untuk menggunakan substitusi dan pengikatan variabel. PostgreSQL menggunakan format $1 , $2 , ... menggunakan MySQL ? terlepas dari lokasi nilainya. Pangkalan data / sql mengusulkan format universal untuk argumen bernama https://golang.org/pkg/database/sql/#NamedArg . Contoh penggunaan:


 db.ExecContext(ctx, `DELETE FROM orders WHERE created_at < @end`, sql.Named("end", endTime)) 

Dukungan untuk format ini lebih baik digunakan dibandingkan dengan solusi PostgreSQL atau MySQL.


Respons dari database yang memproses driver perangkat lunak dapat direpresentasikan secara kondisional sebagai berikut:


 dev > SELECT * FROM rubrics; id | created_at | title | url ----+-------------------------+-------+------------ 1 | 2012-03-13 11:17:23.609 | Tech | technology 2 | 2015-07-21 18:05:43.412 | Style | fashion (2 rows) 

Dari sudut pandang pengguna di tingkat antarmuka, akan lebih mudah untuk menggambarkan parameter output sebagai susunan struktur formulir:


 type GetRubricsResp struct { ID int CreatedAt time.Time Title string URL string } 

Selanjutnya, proyeksikan nilai id pada resp.ID dan seterusnya. Secara umum, fungsi ini mencakup sebagian besar kebutuhan.


Ketika mendeklarasikan pesan melalui struktur data internal, muncul pertanyaan tentang bagaimana mendukung tipe data non-standar. Misalnya, sebuah array. Jika Anda menggunakan driver github.com/lib/pq saat bekerja dengan PostgreSQL, Anda dapat menggunakan fungsi bantu seperti pq.Array(&x) saat menyampaikan argumen kueri atau memindai respons. Contoh dari dokumentasi:


 db.Query(`SELECT * FROM t WHERE id = ANY($1)`, pq.Array([]int{235, 401})) var x []sql.NullInt64 db.QueryRow('SELECT ARRAY[235, 401]').Scan(pq.Array(&x)) 

Oleh karena itu, harus ada cara untuk menyiapkan struktur data.


Saat mengeksekusi salah satu metode antarmuka, koneksi database dapat digunakan dalam bentuk objek *sql.DB Jika Anda perlu menjalankan beberapa metode dalam satu transaksi, saya ingin menggunakan fungsionalitas transparan dengan pendekatan yang sama untuk bekerja di luar transaksi, bukan lewat argumen tambahan.


Ketika bekerja dengan implementasi antarmuka, sangat penting bagi kita untuk dapat menanamkan toolkit. Misalnya, masuk semua permintaan. Toolkit harus mendapatkan akses ke variabel permintaan, kesalahan respons, runtime, nama metode antarmuka.


Sebagian besar, persyaratan dirumuskan sebagai sistematisasi skenario database.


Solusi: go-gad / sal


Salah satu cara untuk menangani kode boilerplate adalah dengan membuatnya. Untungnya, Golang memiliki alat dan contoh untuk https://blog.golang.org/generate ini. GoMock https://github.com/golang/mock , tempat analisis antarmuka dilakukan menggunakan refleksi, dipinjam sebagai solusi arsitektur untuk generasi tersebut. Berdasarkan pendekatan ini, sesuai dengan persyaratan, utilitas salgen dan perpustakaan sal ditulis, yang menghasilkan kode implementasi antarmuka dan menyediakan satu set fungsi tambahan.


Untuk mulai menggunakan solusi ini, perlu untuk mendeskripsikan antarmuka yang menggambarkan perilaku lapisan interaksi dengan database. Tentukan go:generate arahan dengan seperangkat argumen dan mulai generasi. Anda akan mendapatkan konstruktor dan banyak kode boilerplate, siap digunakan.


 package repo import "context" //go:generate salgen -destination=./postgres_client.go -package=dev/taxi/repo dev/taxi/repo Postgres type Postgres interface { CreateDriver(ctx context.Context, r *CreateDriverReq) error } type CreateDriverReq struct { taxi.Driver } func (r *CreateDriverReq) Query() string { return `INSERT INTO drivers(id, name) VALUES(@id, @name)` } 

Antarmuka


Semuanya dimulai dengan mendeklarasikan antarmuka dan perintah khusus untuk utilitas go generate :


 //go:generate salgen -destination=./client.go -package=github.com/go-gad/sal/examples/profile/storage github.com/go-gad/sal/examples/profile/storage Store type Store interface { ... 

Di sini dijelaskan bahwa untuk antarmuka Store kami, salgen utilitas konsol akan dipanggil dari paket, dengan dua opsi dan dua argumen. Opsi -destination pertama menentukan di mana file kode yang dihasilkan akan ditulis. Opsi kedua -package menentukan path lengkap (jalur impor) dari perpustakaan untuk implementasi yang dihasilkan. Berikut ini adalah dua argumen. Yang pertama menjelaskan jalur paket lengkap ( github.com/go-gad/sal/examples/profile/storage ) di mana antarmuka berada, yang kedua menunjukkan nama antarmuka itu sendiri. Perhatikan bahwa perintah untuk go generate dapat ditemukan di mana saja, tidak harus di sebelah antarmuka target.


Setelah menjalankan perintah go generate , kami mendapatkan konstruktor yang namanya dibangun dengan menambahkan awalan New ke nama antarmuka. Konstruktor mengambil parameter yang diperlukan terkait dengan antarmuka sal.QueryHandler :


 type QueryHandler interface { QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) } 

Antarmuka ini sesuai dengan objek *sql.DB


 connStr := "user=pqgotest dbname=pqgotest sslmode=verify-full" db, err := sql.Open("postgres", connStr) client := storage.NewStore(db) 

Metode


Metode antarmuka menentukan set kueri basis data yang tersedia.


 type Store interface { CreateAuthor(ctx context.Context, req CreateAuthorReq) (CreateAuthorResp, error) GetAuthors(ctx context.Context, req GetAuthorsReq) ([]*GetAuthorsResp, error) UpdateAuthor(ctx context.Context, req *UpdateAuthorReq) error } 

  • Jumlah argumen selalu dua.
  • Argumen pertama adalah konteksnya.
  • Argumen kedua berisi data untuk variabel yang mengikat dan mendefinisikan string kueri.
  • Parameter output pertama dapat berupa objek, array objek, atau tidak ada.
  • Parameter keluaran terakhir selalu merupakan kesalahan.

Argumen pertama selalu context.Context . Objek context.Context . Konteks ini akan diteruskan ketika menggunakan database dan toolkit. Argumen kedua mengharapkan parameter dengan struct tipe dasar (atau pointer ke struct ). Parameter harus memenuhi antarmuka berikut:


 type Queryer interface { Query() string } 

Metode Query() akan dipanggil sebelum menjalankan query database. String yang dihasilkan akan dikonversi ke format khusus basis data. Yaitu, untuk PostgreSQL, @end akan diganti dengan $1 , dan nilai &req.End akan diteruskan ke array argumen


Bergantung pada parameter output, ditentukan metode mana (Query / Exec) yang akan dipanggil:


  • Jika parameter pertama adalah struct tipe dasar (atau pointer ke struct ), metode QueryContext akan dipanggil. Jika respons dari database tidak mengandung satu baris, maka kesalahan sql.ErrNoRows akan sql.ErrNoRows . Artinya, perilaku ini mirip dengan db.QueryRow .
  • Jika parameter pertama dengan slice tipe dasar, metode QueryContext akan dipanggil. Jika respons dari database tidak berisi baris, daftar kosong akan dikembalikan. Tipe dasar dari item daftar harus stuct (atau pointer ke struct ).
  • Jika parameter output adalah salah satu dengan tipe error , metode ExecContext akan dipanggil.

Pernyataan yang disiapkan


Kode yang dihasilkan mendukung ekspresi yang disiapkan. Ekspresi yang disiapkan di-cache. Setelah persiapan pertama dari ekspresi, itu di-cache. Pangkalan data / sql sendiri memastikan bahwa ekspresi yang disiapkan diterapkan secara transparan ke koneksi database yang diinginkan, termasuk pemrosesan koneksi tertutup. Pada gilirannya, perpustakaan go-gad/sal menangani penggunaan kembali pernyataan yang disiapkan dalam konteks transaksi. Ketika ekspresi yang disiapkan dieksekusi, argumen dilewatkan menggunakan pengikatan variabel, transparan kepada pengembang.


Untuk mendukung argumen bernama di sisi perpustakaan go-gad/sal , permintaan dikonversi ke tampilan yang sesuai untuk database. Sekarang ada dukungan konversi untuk PostgreSQL. Nama bidang objek kueri digunakan untuk menggantikan dalam argumen bernama. Untuk menentukan nama yang berbeda dan bukan nama bidang objek, Anda harus menggunakan tag sql untuk bidang struktur. Pertimbangkan sebuah contoh:


 type DeleteOrdersRequest struct { UserID int64 `sql:"user_id"` CreateAt time.Time `sql:"created_at"` } func (r * DeleteOrdersRequest) Query() string { return `DELETE FROM orders WHERE user_id=@user_id AND created_at<@end` } 

String kueri akan dikonversi, dan menggunakan tabel korespondensi dan pengikatan variabel, daftar akan diteruskan ke argumen eksekusi kueri:


 // generated code: db.Query("DELETE FROM orders WHERE user_id=$1 AND created_at<$2", &req.UserID, &req.CreatedAt) 

Peta struct untuk meminta argumen dan pesan tanggapan


Pustaka go-gad/sal menangani mengaitkan garis respons basis data dengan struktur respons, kolom tabel dengan bidang struktur:


 type GetRubricsReq struct {} func (r GetRubricReq) Query() string { return `SELECT * FROM rubrics` } type Rubric struct { ID int64 `sql:"id"` CreateAt time.Time `sql:"created_at"` Title string `sql:"title"` } type GetRubricsResp []*Rubric type Store interface { GetRubrics(ctx context.Context, req GetRubricsReq) (GetRubricsResp, error) } 

Dan jika respons basis data adalah:


 dev > SELECT * FROM rubrics; id | created_at | title ----+-------------------------+------- 1 | 2012-03-13 11:17:23.609 | Tech 2 | 2015-07-21 18:05:43.412 | Style (2 rows) 

Kemudian daftar GetRubricsResp akan kembali kepada kami, elemen yang akan menjadi pointer ke Rubrik, di mana bidang diisi dengan nilai-nilai dari kolom yang sesuai dengan nama tag.


Jika respons database berisi kolom dengan nama yang sama, maka bidang struktur yang sesuai akan dipilih dalam urutan deklarasi.


 dev > select * from rubrics, subrubrics; id | title | id | title ----+-------+----+---------- 1 | Tech | 3 | Politics 

 type Rubric struct { ID int64 `sql:"id"` Title string `sql:"title"` } type Subrubric struct { ID int64 `sql:"id"` Title string `sql:"title"` } type GetCategoryResp struct { Rubric Subrubric } 

Tipe data non-standar


Paket database/sql menyediakan dukungan untuk tipe data dasar (string, angka). Untuk memproses tipe data seperti array atau json dalam permintaan atau respons, perlu untuk mendukung driver.Valuer dan sql.Scanner . sql.Scanner . Implementasi driver yang berbeda memiliki fungsi pembantu khusus. Misalnya lib/pq.Array ( https://godoc.org/github.com/lib/pq#Array ):


 func Array(a interface{}) interface { driver.Valuer sql.Scanner } 

Secara default, pustaka go-gad/sql untuk bidang struktur tampilan


 type DeleteAuthrosReq struct { Tags []int64 `sql:"tags"` } 

akan menggunakan nilai &req.Tags . Jika struktur memenuhi antarmuka sal.ProcessRower ,


 type ProcessRower interface { ProcessRow(rowMap RowMap) } 

maka nilai yang digunakan bisa disesuaikan


 func (r *DeleteAuthorsReq) ProcessRow(rowMap sal.RowMap) { rowMap.Set("tags", pq.Array(r.Tags)) } func (r *DeleteAuthorsReq) Query() string { return `DELETE FROM authors WHERE tags=ANY(@tags::UUID[])` } 

Handler ini dapat digunakan untuk argumen permintaan dan respons. Dalam kasus daftar dalam respons, metode harus dimiliki item daftar.


Transaksi


Untuk mendukung transaksi, antarmuka (Store) harus diperluas dengan metode berikut:


 type Store interface { BeginTx(ctx context.Context, opts *sql.TxOptions) (Store, error) sal.Txer ... 

Implementasi metode akan dihasilkan. Metode BeginTx menggunakan koneksi dari objek sal.QueryHandler saat ini dan membuka transaksi db.BeginTx(...) ; mengembalikan objek implementasi baru dari antarmuka Store , tetapi menggunakan objek *sql.Tx diterima sebagai *sql.Tx


Middleware


Kait disediakan untuk alat penyemat.


 type BeforeQueryFunc func(ctx context.Context, query string, req interface{}) (context.Context, FinalizerFunc) type FinalizerFunc func(ctx context.Context, err error) 

Hook BeforeQueryFunc akan dipanggil sebelum db.PrepareContext atau db.Query . Yaitu, pada awal program, ketika cache ekspresi yang disiapkan kosong, ketika store.GetAuthors dipanggil, kait BeforeQueryFunc akan dipanggil dua kali. Pengait BeforeQueryFunc dapat mengembalikan pengait FinalizerFunc , yang akan dipanggil sebelum keluar dari metode pengguna, di case store.GetAuthors , menggunakan defer .


Pada saat pelaksanaan kait, konteksnya diisi dengan kunci layanan dengan nilai-nilai berikut:


  • ctx.Value(sal.ContextKeyTxOpened) boolean menentukan apakah metode ini dipanggil dalam konteks transaksi atau tidak.
  • ctx.Value(sal.ContextKeyOperationType) , nilai string dari jenis operasi, "QueryRow" , "Query" , "Exec" , "Commit" , dll.
  • ctx.Value(sal.ContextKeyMethodName) string dari metode antarmuka, seperti "GetAuthors" .

Sebagai argumen, kait BeforeQueryFunc menerima string sql dari kueri dan argumen req dari metode kueri pengguna. Pengait FinalizerFunc mengambil variabel err sebagai argumen.


 beforeHook := func(ctx context.Context, query string, req interface{}) (context.Context, sal.FinalizerFunc) { start := time.Now() return ctx, func(ctx context.Context, err error) { log.Printf( "%q > Opeartion %q: %q with req %#v took [%v] inTx[%v] Error: %+v", ctx.Value(sal.ContextKeyMethodName), ctx.Value(sal.ContextKeyOperationType), query, req, time.Since(start), ctx.Value(sal.ContextKeyTxOpened), err, ) } } client := NewStore(db, sal.BeforeQuery(beforeHook)) 

Contoh keluaran:


 "CreateAuthor" > Opeartion "Prepare": "INSERT INTO authors (Name, Desc, CreatedAt) VALUES($1, $2, now()) RETURNING ID, CreatedAt" with req <nil> took [50.819µs] inTx[false] Error: <nil> "CreateAuthor" > Opeartion "QueryRow": "INSERT INTO authors (Name, Desc, CreatedAt) VALUES(@Name, @Desc, now()) RETURNING ID, CreatedAt" with req bookstore.CreateAuthorReq{BaseAuthor:bookstore.BaseAuthor{Name:"foo", Desc:"Bar"}} took [150.994µs] inTx[false] Error: <nil> 

Apa selanjutnya


  • Dukungan untuk variabel yang mengikat dan ekspresi yang disiapkan untuk MySQL.
  • Kait RowAppender untuk menyesuaikan respons.
  • Mengembalikan nilai Exec.Result .

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


All Articles