基于接口的Golang数据库客户端生成器 。

为了使用数据库,Golang提供了database/sql
包,它是关系数据库编程接口的抽象。 一方面,该软件包包括强大的功能,用于管理连接池,使用准备好的语句,事务和数据库查询界面。 另一方面,您必须在Web应用程序中编写大量与数据库交互的相同类型的代码。 go-gad / sal库以基于描述的界面生成相同类型的代码的形式提供了一种解决方案。
动机
如今,有足够数量的库以ORM的形式提供解决方案,提供用于构建查询的帮助器,基于数据库模式生成帮助器的形式。
几年前,当我切换到Golang语言时,我已经有使用不同语言的数据库的经验。 使用ORM,例如ActiveRecord,并且不使用。 从爱恨转为厌烦,编写几行额外的代码没有问题,在Golang中与数据库进行交互产生了诸如存储库模式之类的东西。 我们描述了与数据库一起使用的接口,我们使用标准的db.Query,row.Scan实现了该接口。 使用额外的包装程序根本没有意义,因为它是不透明的,因此必须保持警惕。
SQL语言本身已经是您的程序与存储库中的数据之间的抽象。 试图描述一个数据方案,然后建立复杂的查询,对我来说总是不合逻辑的。 在这种情况下,响应结构与数据方案不同。 事实证明,无需在数据方案级别描述合同,而在请求和响应级别描述合同。 描述API请求和响应的数据结构时,我们在Web开发中使用了这种方法。 使用RESTful JSON或gRPC访问服务时,我们使用JSON Schema或Protobuf在请求和响应级别声明合同,而不是在服务内的实体的数据模式下声明合同。
也就是说,与数据库的交互归结为一种类似的方法:
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) {
这样可以使您的程序可预测。 但是老实说,这不是诗人的梦想。 我们希望减少编写查询,填充数据结构,使用变量绑定等样板代码的数量。 我试图列出所需的实用程序集应满足的要求列表。
要求条件
- 以接口形式描述交互。
- 该接口由请求和响应的方法和消息描述。
- 支持绑定变量和准备好的语句。
- 支持命名参数。
- 将数据库响应链接到消息数据结构的字段。
- 支持非典型数据结构(数组,json)。
- 透明地处理事务。
- 对中间件的本机支持。
我们想使用该接口来抽象与数据库交互的实现。 这将使我们能够实现类似于设计模式的内容,例如存储库。 在上面的示例中,我们描述了Store接口。 现在我们可以将其用作依赖项。 在测试阶段,我们可以传递基于此接口生成的存根,并且在产品中,我们将使用基于Postgres结构的实现。
每种接口方法都描述一个数据库查询。 方法的输入和输出参数必须是请求合同的一部分。 查询字符串需要能够根据输入参数进行格式化。 在编译具有复杂采样条件的查询时尤其如此。
编译查询时,我们要使用替换和变量绑定。 例如,在PostgreSQL中,您编写$1
而不是值,并与查询一起传递一个参数数组。 第一个参数将用作转换后的查询中的值。 对预备表达式的支持使您不必担心组织这些相同表达式的存储。 数据库/ sql库提供了一个强大的工具来支持准备好的表达式;它本身负责连接池,关闭的连接。 但是对于用户而言,必须执行其他操作才能在事务中重用准备好的表达式。
PostgreSQL和MySQL等数据库使用不同的语法来使用替换和变量绑定。 PostgreSQL使用$1
, $2
,... MySQL格式?
无论值的位置如何。 数据库/ sql库为命名参数https://golang.org/pkg/database/sql/#NamedArg提出了一种通用格式。 用法示例:
db.ExecContext(ctx, `DELETE FROM orders WHERE created_at < @end`, sql.Named("end", endTime))
与PostgreSQL或MySQL解决方案相比,最好使用此格式的支持。
来自处理软件驱动程序的数据库的响应可以有条件地表示为:
dev > SELECT * FROM rubrics; id | created_at | title | url
从用户的角度来看,将输出参数描述为以下形式的结构数组很方便:
type GetRubricsResp struct { ID int CreatedAt time.Time Title string URL string }
接下来,将id
值resp.ID
到resp.ID
等上。 通常,此功能可以满足大多数需求。
通过内部数据结构声明消息时,出现了如何支持非标准数据类型的问题。 例如,一个数组。 如果在使用PostgreSQL时使用github.com/lib/pq驱动程序,则在传递查询参数或扫描响应时可以使用pq.Array(&x)
等辅助功能。 文档中的示例:
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))
因此,必须有准备数据结构的方法。
执行任何接口方法时,都可以使用*sql.DB
的形式使用数据库连接。 如果您需要在单个事务中执行多种方法,我想使用透明功能以及类似的方法在事务外部工作,而不要传递其他参数。
在使用接口实现时,对我们而言至关重要的是能够嵌入该工具包。 例如,记录所有请求。 该工具箱必须访问请求变量,响应错误,运行时,接口方法名称。
在大多数情况下,需求被表述为数据库方案的系统化。
解决方案:go-gad / sal
处理样板代码的一种方法是生成它。 幸运的是,Golang具有用于此https://blog.golang.org/generate的工具和示例。 GoMock的https://github.com/golang/mock方法被用作这一代的体系结构解决方案,其中使用反射来执行接口分析。 基于此方法,根据要求,编写了salgen实用程序和sal库,它们生成接口实现代码并提供一组辅助功能。
为了开始使用此解决方案,必须描述一个描述交互层与数据库行为的接口。 使用一组参数指定go:generate
指令并开始生成。 您将获得一个构造函数和一堆样板代码,随时可以使用。
package repo import "context"
介面
一切都从声明接口和go generate
实用程序的特殊命令开始:
这里描述了对于我们的Store
界面,将从包中调用控制台实用程序salgen
,它带有两个选项和两个参数。 第一个选项-destination
确定所生成的代码将写入哪个文件中。 第二个选项-package
为生成的实现定义库的完整路径(导入路径)。 以下是两个参数。 第一个描述接口所在的完整包路径( github.com/go-gad/sal/examples/profile/storage
),第二个描述接口本身的名称。 请注意,用于go generate
的命令可以位于任何位置,而不必位于目标接口旁边。
执行go generate
命令后,我们得到一个构造函数,该构造函数的名称是通过在接口名称上添加New
前缀来构建的。 构造函数采用与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) }
此接口对应于*sql.DB
对象。
connStr := "user=pqgotest dbname=pqgotest sslmode=verify-full" db, err := sql.Open("postgres", connStr) client := storage.NewStore(db)
方法
接口方法确定可用数据库查询的集合。
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 }
- 参数的数量始终严格为2。
- 第一个参数是上下文。
- 第二个参数包含用于绑定变量的数据并定义查询字符串。
- 第一输出参数可以是一个对象,一个对象数组或不存在。
- 最后的输出参数始终是错误。
第一个参数始终是context.Context
对象。 调用数据库和工具箱时,将传递此上下文。 第二个参数需要一个基本类型为struct
的参数(或指向struct
的指针)。 该参数必须满足以下接口:
type Queryer interface { Query() string }
在执行数据库查询之前,将调用Query()
方法。 结果字符串将转换为特定于数据库的格式。 也就是说,对于PostgreSQL,@ @end
将被替换为$1
,并且值&req.End
将被传递给参数数组
根据输出参数,确定将调用哪种方法(查询/执行):
- 如果第一个参数的基本类型为
struct
(或struct
的指针),则将调用QueryContext
方法。 如果来自数据库的响应不包含单个行,则将sql.ErrNoRows
错误。 也就是说,该行为类似于db.QueryRow
。 - 如果第一个参数具有基本类型
slice
,则将调用QueryContext
方法。 如果来自数据库的响应不包含行,则将返回一个空列表。 列表项的基本类型必须为stuct
(或指向struct
的指针)。 - 如果输出参数为
error
类型之一,则将调用ExecContext
方法。
准备好的陈述
生成的代码支持准备好的表达式。 预备的表达式被缓存。 第一次准备表达式后,将对其进行缓存。 数据库/ sql库本身确保将准备好的表达式透明地应用于所需的数据库连接,包括处理封闭的连接。 反过来, go-gad/sal
库负责在事务上下文中重用准备好的语句。 执行准备好的表达式时,将使用对开发人员透明的变量绑定传递参数。
为了在go-gad/sal
库方面支持命名参数,该请求将转换为适合数据库的视图。 现在有对PostgreSQL的转换支持。 查询对象的字段名称用于替换命名参数。 要指定其他名称而不是对象字段名称,必须对结构字段使用sql
标记。 考虑一个例子:
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` }
将转换查询字符串,并使用对应表和变量绑定将列表传递给查询执行参数:
将结构映射到请求的参数和响应消息
go-gad/sal
库负责将数据库响应行与响应结构,表列与结构字段相关联:
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) }
并且如果数据库响应为:
dev > SELECT * FROM rubrics; id | created_at | title
然后,GetRubricsResp列表将返回给我们,其中的元素将是指向Rubric的指针,在该字段中,字段中填充了与标签名称相对应的列中的值。
如果数据库响应包含具有相同名称的列,则将按照声明顺序选择相应的结构字段。
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 }
非标准数据类型
database/sql
软件包提供对基本数据类型(字符串,数字)的支持。 为了处理请求或响应中的数据类型(例如数组或json),必须支持driver.Valuer
和sql.Scanner
。 不同的驱动程序实现具有特殊的助手功能。 例如lib/pq.Array
( https://godoc.org/github.com/lib/pq#Array ):
func Array(a interface{}) interface { driver.Valuer sql.Scanner }
默认情况下,用于视图结构字段的go-gad/sql
库
type DeleteAuthrosReq struct { Tags []int64 `sql:"tags"` }
将使用值&req.Tags
。 如果结构满足sal.ProcessRower
接口,
type ProcessRower interface { ProcessRow(rowMap RowMap) }
然后可以调整使用的值
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[])` }
该处理程序可用于请求和响应参数。 如果响应中有列表,则该方法必须属于列表项。
交易次数
为了支持事务,应使用以下方法扩展接口(商店):
type Store interface { BeginTx(ctx context.Context, opts *sql.TxOptions) (Store, error) sal.Txer ...
将生成方法的实现。 BeginTx
方法使用来自当前sal.QueryHandler
对象的连接,并打开事务db.BeginTx(...)
; 返回Store
接口的新实现对象,但将接收到的*sql.Tx
对象用作*sql.Tx
中间件
提供了用于嵌入工具的挂钩。
type BeforeQueryFunc func(ctx context.Context, query string, req interface{}) (context.Context, FinalizerFunc) type FinalizerFunc func(ctx context.Context, err error)
在db.Query
db.PrepareContext
或db.Query
之前,将调用BeforeQueryFunc
挂钩。 也就是说,在程序开始时,当准备好的表达式缓存为空时,当调用BeforeQueryFunc
时, BeforeQueryFunc
挂钩将被调用两次。 在我们的案例store.GetAuthors
, BeforeQueryFunc
挂钩可以返回FinalizerFunc
挂钩,在退出用户方法之前将使用defer
调用该FinalizerFunc
。
在执行挂钩时,上下文中将填充具有以下值的服务密钥:
ctx.Value(sal.ContextKeyTxOpened)
布尔值确定是否在事务上下文中调用该方法。ctx.Value(sal.ContextKeyOperationType)
,操作类型的字符串值, "QueryRow"
, "Query"
, "Exec"
, "Commit"
等。ctx.Value(sal.ContextKeyMethodName)
接口方法ctx.Value(sal.ContextKeyMethodName)
字符串值,例如"GetAuthors"
。
作为参数, BeforeQueryFunc
挂钩接受查询的sql字符串和用户查询方法的req
参数。 FinalizerFunc
挂钩使用err
变量作为参数。
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))
输出示例:
"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>
接下来是什么
- 支持MySQL的绑定变量和预备表达式。
- RowAppender挂钩可调整响应。
- 返回
Exec.Result
的值。