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) {
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
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"
Antarmuka
Semuanya dimulai dengan mendeklarasikan antarmuka dan perintah khusus untuk utilitas go generate
:
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:
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
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
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
.