找回密码
 会员注册
查看: 8|回复: 0

如何用go实现一个ORM

[复制链接]

5

主题

0

回帖

16

积分

新手上路

积分
16
发表于 2024-10-7 14:45:33 | 显示全部楼层 |阅读模式
本期作者洪胜杰B端技术中心高级开发工程师为了提高开发效率和质量,我们常常需要ORM来帮助我们快速实现持久层增删改查API,目前go语言实现的ORM有很多种,他们都有自己的优劣点,有的实现简单,有的功能复杂,有的API十分优雅。在使用了多个类似的工具之后,总是会发现某些点无法满足解决我们生产环境中碰到的实际问题,比如无法集成公司内部的监控,Trace组件,没有database层的超时设置,没有熔断等,所以有必要公司自己内部实现一款满足我们可自定义开发的ORM,好用的生产工具常常能够对生产力产生飞跃式的提升。为什么需要ORM直接使用database/sql的痛点首先看看用database/sql如何查询数据库我们用user表来做例子,一般的工作流程是先做技术方案,其中排在比较前面的是数据库表的设计,大部分公司应该有严格的数据库权限控制,不会给线上程序使用比较危险的操作权限,比如创建删除数据库,表,删除数据等。表结构如下:CREATE TABLE `user` ( ?`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id', ?`name` varchar(100) NOT NULL COMMENT '名称', ?`age` int(11) NOT NULL DEFAULT '0' COMMENT '年龄', ?`ctime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', ?`mtime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间', ?PRIMARY KEY (`id`),) ENGINE=InnoDB ?DEFAULT CHARSET=utf8mb4首先我们要写出和表结构对应的结构体User,如果你足够勤奋和努力,相应的json tag 和注释都可以写上,这个过程无聊且重复,因为在设计表结构的时候你已经写过一遍了。type User struct { ? ?Id ? ?int64 ? ? `json:"id"` ? ? ?Name ?string ? ?`json:"name"` ? ?Age ? int64 ? ? ? ?Ctime time.Time ? ?Mtime time.Time ?// 更新时间}定义好结构体,我们写一个查询年龄在20以下且按照id字段顺序排序的前20名用户的 go代码func FindUsers(ctx context.Context) ([]*User, error) { ? ?rows, err := db.QueryContext(ctx, "SELECT `id`,`name`,`age`,`ctime`,`mtime` FROM user WHERE `age` ?") ? ? ? ?s.args = append(s.args, arg) ? ?}}func (s *SelectBuilder) Query() (string, []interface{}) { ? ?s.builder.WriteString("SELECT ") ? ?for k, v := range s.column { ? ? ? ?if k > 0 { ? ? ? ? ? ?s.builder.WriteString(",") ? ? ? ?} ? ? ? ?s.builder.WriteString("`" + v + "`") ? ?} ? ?s.builder.WriteString(" FROM ") ? ?s.builder.WriteString("`" + s.tableName + "` ") ? ?if len(s.where) > 0 { ? ? ? ?s.builder.WriteString("WHERE ") ? ? ? ?for k, f := range s.where { ? ? ? ? ? ?if k > 0 { ? ? ? ? ? ? ? ?s.builder.WriteString(" AND ") ? ? ? ? ? ?} ? ? ? ? ? ?f(s) ? ? ? ?} ? ?} ? ?if s.orderby != "" { ? ? ? ?s.builder.WriteString(" ORDER BY " + s.orderby) ? ?} ? ?if s.limit != nil { ? ? ? ?s.builder.WriteString(" LIMIT ") ? ? ? ?s.builder.WriteString(strconv.FormatInt(*s.limit, 10)) ? ?} ? ?if s.offset != nil { ? ? ? ?s.builder.WriteString(" OFFSET ") ? ? ? ?s.builder.WriteString(strconv.FormatInt(*s.offset, 10)) ? ?} ? ?return s.builder.String(), s.args}通过结构体上的方法调用返回自身,使其具有链式调用能力,并通过方法调用设置结构体中的值,用以构成SQL语句需要的元素。SelectBuilder 包含性能较高的strings.Builder 来拼接字符串。Query()方法构建出真正的SQL语句,返回包含占位符的SQL语句和args参数。[]func(s *SelectBuilder)通过函数数组来创建查询条件,可以通过函数调用的顺序和层级来生成 AND OR这种有嵌套关系的查询条件子句。Where()?传入的是查询条件函数,为可变参数列表,查询条件之间默认是AND关系。外部使用起来效果:b := SelectBuilder{builder: &strings.Builder{}}sql, args := b. ? ?Select("id", "name", "age", "ctime", "mtime"). ? ?From("user"). ? ?Where(GT("id", 0), GT("age", 0)). ? ?OrderBy("id"). ? ?Limit(0, 20). ? ?Query()Scanner的实现顾名思义Scanner的作用就是把查询结果设置到对应的go对象上去,完成关系和对象的映射,关键核心就是通过反射获知传入对象的类型和字段类型,通过反射创建对象和值,并通过golang结构体的字段后面的tag来和查询结果的表头一一对应,达到动态给结构字段赋值的能力。具体实现如下:func ScanSlice(rows *sql.Rows, dst interface{}) error { ? ?defer rows.Close() ? ?// dst的地址 ? ?val := reflect.ValueOf(dst) // ?&[]*main.User ? ?// 判断是否是指针类型,go是值传递,只有传指针才能让更改生效 ? ?if val.Kind() != reflect.Ptr { ? ? ? ?return errors.New("dst not a pointer") ? ?} ? ?// 指针指向的Value ? ?val = reflect.Indirect(val) // []*main.User ? ?if val.Kind() != reflect.Slice { ? ? ? ?return errors.New("dst not a pointer to slice") ? ?} ? ?// 获取slice中的类型 ? ?struPointer := val.Type().Elem() // *main.User ? ?// 指针指向的类型 具体结构体 ? ?stru := struPointer.Elem() ? ? ?// ?main.User ? ?cols, err := rows.Columns() ?// [id,name,age,ctime,mtime] ? ?if err != nil { ? ? ? ?return err ? ?} ? ?// 判断查询的字段数是否大于 结构体的字段数 ? ?if stru.NumField() field idx ? ?for i := 0; i ? AND `age` > ? ORDER BY id LIMIT 20 OFFSET 0 ?[0 0]自动生成通过上面的使用的例子来看,我们的工作轻松了不少:第一:SQL语句不需要硬编码了;第二:Scan不需要写大量结构体字段和的乏味的重复代码。着实帮我们省了很大的麻烦。但是查询字段还需要我们自己手写,像这种Select("id", "name", "age", "ctime", "mtime").其中传入的字段需要我们硬编码,我们可不可以再进一步,通过表结构定义来生成我们的golang结构体呢?答案是肯定的,要实现这一步我们需要一个SQL语句的解析器(https://github.com/xwb1989/sqlparser),把SQL DDL语句解析成go语言中如下的Table对象,其所包含的表名,列名、列类型、注释等都能获取到,再通过这些对象和写好的模板代码来生成我们实际业务使用的代码。Table对象如下:type Table struct { ? ?TableName ? string ? ?// table name ? ?GoTableName string ? ?// go struct name ? ?PackageName string ? ?// package name ? ?Fields ? ? ?[]*Column // columns}type Column struct { ? ?ColumnName ? ?string // column_name ? ?ColumnType ? ?string // column_type ? ?ColumnComment string // column_comment}使用以上Table对象的模板代码:type {{.GoTableName}} struct { ? ?{{- range .Fields }} ? ? ? ?{{ .GoColumnName }} {{ ?.GoColumnType }} `json:"{{ .ColumnName }}"` // {{ .ColumnComment }} ? ?{{- end}}}const ( ? ?table = "{{.TableName}}" ? ?{{- range .Fields}} ? ? ? ?{{ .GoColumnName}} = "{{.ColumnName}}" ? ?{{- end }})var columns = []string{ ? ?{{- range .Fields}} ? ?{{ .GoColumnName}}, ? ?{{- end }}}通过上面的模板我们用user表的建表SQL语句生成如下代码:type User struct { ? ?Id ? ?int64 ? ? `json:"id"` ? ?// id字段 ? ?Name ?string ? ?`json:"name"` ?// 名称 ? ?Age ? int64 ? ? `json:"age"` ? // 年龄 ? ?Ctime time.Time `json:"ctime"` // 创建时间 ? ?Mtime time.Time `json:"mtime"` // 更新时间}const ( ? ?table = "user" ? ?Id = "id" ? ?Name = "name" ? ?Age = "age" ? ?Ctime = "ctime" ? ?Mtime = "mtime")var Columns = []string{"id","name","age","ctime","mtime"}那么我们在查询的时候就可以这样使用Select(Columns...)通过模板自动生成代码,可以大大的减轻开发编码负担,使我们从繁重的代码中解放出来。reflect真的有必要吗?由于我们SELECT时选择查找的字段和顺序是不固定的,我们有可能 SELECT id, name, age FROM user,也可能 SELECT name, id FROM user,有很大的任意性,这种情况使用反射出来的结构体tag和查询的列名来确定映射关系是必须的。但是有一种情况我们不需要用到反射,而且是一种最常用的情况,即:查询的字段名和表结构的列名一致,且顺序一致。这时候我们可以这么写,通过DeepEqual来判断查询字段和表结构字段是否一致且顺序一致来决定是否通过反射还是通过传统方法来创建对象。用传统方式创建对象(如下图第12行)令我们编码痛苦,不过可以通过模板来自动生成下面的代码,以避免手写,这样既灵活方便好用,性能又没有损耗,看起来是一个比较完美的解决方案。func FindUserNoReflect(b *SelectBuilder) ([]*User, error) { ? ?sql, args := b.Query() ? ?rows, err := db.QueryContext(ctx, sql, args...) ? ?if err != nil { ? ? ? ?return nil, err ? ?} ? ?result := []*User{} ? ?if DeepEqual(b.column, Columns) { ? ? ? ?defer rows.Close() ? ? ? ?for rows.Next() { ? ? ? ? ? ?a := &User{} ? ? ? ? ? ?if err := rows.Scan(&a.Id, &a.Name, &a.Age, &a.Ctime, &a.Mtime); err != nil { ? ? ? ? ? ? ? ?return nil, err ? ? ? ? ? ?} ? ? ? ? ? ?result = append(result, a) ? ? ? ?} ? ? ? ?if rows.Err() != nil { ? ? ? ? ? ?return nil, rows.Err() ? ? ? ?} ? ? ? ?return result, nil ? ?} ? ?err = ScanSlice(rows, &result) ? ?if err != nil { ? ? ? ?return nil, err ? ?} ? ?return result, nil}总结通过database/sql 库开发有较大痛点,ORM就是为了解决以上问题而生,其存在是有意义的。ORM 两个关键的部分是SQLBuilder和Scanner的实现。ORM Scanner 使用反射创建对象在性能上肯定会有一定的损失,但是带来极大的灵活性,同时在查询全表字段这种特殊情况下规避使用反射来提高性能。展望通过表结构,我们可以生成对应的结构体和持久层增删改查代码,我们再往前扩展一步,能否通过表结构生成的proto格式的message,以及一些常用的CRUD GRPC rpc接口定义。通过工具,我们甚至可以把前端的代码都生成好,实现半自动化编程。我想这个是值得期待的。参考资料[1] https://github.com/ent/ent以上是今天的分享内容,如果你有什么想法或疑问,欢迎大家在留言区与我们互动,如果喜欢本期内容的话,欢迎点个“在看”吧!往期精彩指路B站取数服务演进之路单机200万PPS的STUN服务器优化实践B站流程引擎设计与实践
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-10 23:06 , Processed in 0.428496 second(s), 26 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表