元数据的构建

什么是元数据

orm 框架一般需要定义表的模型,然后模型与表生成映射关系,那么就一定少不了解析模型然后找到与之映射的数据库表,所以,元数据是解析模型获得的,这些元数据将被用于构建 SQL、执行校验,以及用 于处理结果集。

模型:一般是指对应到数据库表的 Go 结构体定 义,也被称为 Schema、Table、Meta 等

开源实例

Beego orm

Gorm

设计总结

不管是哪个框架,要考虑保存对应的表的元数据信息,那就避免不了,Model 和 Field 这两个类,Model 保存表维度的信息;Field 保存字段维度的信息;

Model: 表名、索引、主键、关联关系

Field:列名、Go 类型、数据库类型、是否主键、是否外键……

由此可以推断,开源实例的元数据设计看上去很复杂但其设计的演化过程如下:

解析模型

元数据最简单的版本如下:

// field 字段
type Field struct {
    colName string
}
​
type Model struct {
    // tableName 结构体对应的表名
    tableName string
    // 字段名到字段的元数据
    fieldMap map[string]*Field
}

其实从已有的功能 From 和 Where 来看,那么最少也就需要两个东西:列名、表名;现在如何吧这两个信息解析出来呢?答案就是利用 go 的反射机制; 这里可以优先参考常见的开源实例如下,看看这里是怎么做的。

Beego orm

beego orm 采用了用 reflect.Value 接收待解析的模型类参数。

一般来说考虑组合定义的模型时,就难免需要使用递归

Gorm

Gorm 总体上也是设计思想也是相近的,不过它将元数据模型称为 Schema,解析模型得到元数据的代码在 Parse 方法中,方法的逻辑中展示了 Gorm 支持什 么样的模型定义。

逐个字段解析,可以清晰看到,它只解析公开字段。 GORM 利用 tag 来允许用户设置一些对字段的描述, 例如是否是主键、是否允许自增。从官网也可以看到定义模型,使用 tag。

那么接下来,可以思考一下,实际在自己的 orm 框架中;元数据该怎么定义:

type model struct {
   // tableName 结构体对应的表名
   tableName string
   fieldMap  map[string]*field
}
​
// field 字段
type field struct {
   colName string
}

当模型相关的类型定义好后,就开始着眼于要如何解析模型了,模型解析时要考虑通常在 go 开发中,结构体的命名一般采取驼峰式命名;这时将这个结构体映射到数据库表中,必然要考虑如何将驼峰式的命名作一个转换的问题;这里采用驼峰转下划线的方式将模型名称转换为数据库表名,例如:在用户不特殊指定表名 的情况下,如果表模型结构体为 TestModel{} 那么转换到数据库中的表名为 test_model; 包括结构体中的字段也是一样的。

func parseModel(val any) (*Model, error) {
    if val == nil {
        return nil, errs.ErrInputNil
    }
    ptrTyp := reflect.TypeOf(val)
    typ := ptrTyp.Elem()
    if ptrTyp.Kind() != reflect.Ptr || typ.Kind() != reflect.Struct {
        return nil, errs.ErrPointerOnly
    }
​
    // 获得字段的数量
    numField := typ.NumField()
    fds := make(map[string]*Field, numField)
    for i := 0; i < numField; i++ {
        fdType := typ.Field(i)
        fds[fdType.Name] = &Field{
            colName: underscoreName(fdType.Name),
        }
    }
    return &Model{
        tableName: underscoreName(typ.Name()),
        fieldMap:  fds,
    }, nil
}
​
// underscoreName 驼峰转字符串命名
func underscoreName(tableName string) string {
    var buf []byte
    for i, v := range tableName {
        if unicode.IsUpper(v) {
            if i != 0 {
                buf = append(buf, '_')
            }
            buf = append(buf, byte(unicode.ToLower(v)))
        } else {
            buf = append(buf, byte(v))
        }
    }
    return string(buf)
}

注意: 这里模型的解析,只支持一级指针

另外,这里的 error 也是单独拆出来定义的,也叫中心式的 error 定义;从长远维护,或者考虑将来要对 error 进行改造来看,一个集中创建 error 的地方能更高效。

var (
    // ErrPointerOnly 只支持一级指针作为输入
    // 看到这个 error 说明你输入了其它的东西
    // 我们并不希望用户能够直接使用 err == ErrPointerOnly
    // 所以放在我们的 internal 包里
    ErrPointerOnly = errors.New("orm: 只支持一级指针作为输入,例如 *User")
)
​
// NewErrUnknownField 返回代表未知字段的错误
// 一般意味着你可能输入的是列名,或者输入了错误的字段名
func NewErrUnknownField(fd string) error {
    return fmt.Errorf("orm: 未知字段 %s", fd)
}
​
// NewErrUnsupportedExpressionType 返回一个不支持该 expression 错误信息
func NewErrUnsupportedExpressionType(exp any) error {
    return fmt.Errorf("orm: 不支持的表达式 %v", exp)
}

部分错误需要错误信息,那么也可以用定义方法来创建。 wrap error 也可以;

接下来就是顺着解析模型,去改造 Selector 了:

// Selector 用于构造 SELECT 语句
type Selector[T any] struct {
    sb    strings.Builder
    args  []any
    table string
    where []Predicate
    model *model
}
​
// From 指定表名,如果是空字符串,那么将会使用默认表名
func (s *Selector[T]) From(tbl string) *Selector[T] {
    s.table = tbl
    return s
}
​
func (s *Selector[T]) Build() (*Query, error) {
    var (
        t   T
        err error
    )
    s.model, err = parseModel(&t)
    if err != nil {
        return nil, err
    }
    s.sb.WriteString("SELECT * FROM ")
    if s.table == "" {
        s.sb.WriteByte('`')
        s.sb.WriteString(s.model.tableName)
        s.sb.WriteByte('`')
    } else {
        s.sb.WriteString(s.table)
    }
​
    // 构造 WHERE
    if len(s.where) > 0 {
        // 类似这种可有可无的部分,都要在前面加一个空格
        s.sb.WriteString(" WHERE ")
        p := s.where[0]
        for i := 1; i < len(s.where); i++ {
            p = p.And(s.where[i])
        }
        if err := s.buildExpression(p); err != nil {
            return nil, err
        }
    }
    s.sb.WriteString(";")
    return &Query{
        SQL:  s.sb.String(),
        Args: s.args,
    }, nil
}
​
func (s *Selector[T]) buildExpression(e Expression) error {
    if e == nil {
        return nil
    }
    switch exp := e.(type) {
    case Column:
        fd, ok := s.model.fieldMap[exp.name]
        if !ok {
            return errs.NewErrUnknownField(exp.name)
        }
        s.sb.WriteByte('`')
        s.sb.WriteString(fd.colName)
        s.sb.WriteByte('`')
    case value:
        s.sb.WriteByte('?')
        s.args = append(s.args, exp.val)
    case Predicate:
        _, lp := exp.left.(Predicate)
        if lp {
            s.sb.WriteByte('(')
        }
        if err := s.buildExpression(exp.left); err != nil {
            return err
        }
        if lp {
            s.sb.WriteByte(')')
        }
​
        s.sb.WriteByte(' ')
        s.sb.WriteString(exp.op.String())
        s.sb.WriteByte(' ')
​
        _, rp := exp.right.(Predicate)
        if rp {
            s.sb.WriteByte('(')
        }
        if err := s.buildExpression(exp.right); err != nil {
            return err
        }
        if rp {
            s.sb.WriteByte(')')
        }
    default:
        return errs.NewErrUnsupportedExpressionType(exp)
    }
    return nil
}
​
// Where 用于构造 WHERE 查询条件。如果 ps 长度为 0,那么不会构造 WHERE 部分
func (s *Selector[T]) Where(ps ...Predicate) *Selector[T] {
    s.where = ps
    return s
}
​
​
func NewSelector[T any]() *Selector[T] {
    return &Selector[T]{}
}

元数据注册中心

它有这样一个问题:每个 Selector 都要解析一 遍,即便是我们测试的 TestModel 也是重复解析 来解析去。能不能一个类型只解析一次?比如说 TestModel 只需要解析一次,后面的就复用前面解析的结果?

func (s *Selector[T]) Build() (*Query, error) {
    var (
        t   T
        err error
    )
    s.model, err = parseModel(&t)  // 这里每个 Selector 都会重复解析
    if err != nil {
        return nil, err
    }
    s.sb.WriteString("SELECT * FROM ")
    if s.table == "" {
        s.sb.WriteByte('`')
        s.sb.WriteString(s.model.tableName)
        s.sb.WriteByte('`')
    } else {
        s.sb.WriteString(s.table)
    }
​
    // 构造 WHERE
    if len(s.where) > 0 {
        // 类似这种可有可无的部分,都要在前面加一个空格
        s.sb.WriteString(" WHERE ")
        p := s.where[0]
        for i := 1; i < len(s.where); i++ {
            p = p.And(s.where[i])
        }
        if err := s.buildExpression(p); err != nil {
            return nil, err
        }
    }
    s.sb.WriteString(";")
    return &Query{
        SQL:  s.sb.String(),
        Args: s.args,
    }, nil
}

答案是可以的,通过一个公共的模块来存放该元数据可以达到该效果,这个公共的模块就叫做元数据注册中心;那元数据注册中心要如何定义?

全局map:

var models = map[reflect.Type]*model{}
  • 缺乏扩展性:无法在 models 上定义任何方法

  • 缺乏隔离性:如果不同 DB 之间需要隔离,那么 毫无办法

  • 难以测试:包变量的天然缺点,会间接引起不同 测试之间的耦合

定义了 registry,但是维持全局一个实例:

  • 难以测试:包变量的天然缺点,会间接引起不 同测试之间的耦合

在 Go 的 SDK 里面经常能见到类似的设计。 大概可以看做:

  • 有一个接口

  • 有一个默认实现

  • 维持一个包变量的默认实例

  • 提供包方法,直接操作默认实例

Beego 里面的 modelCache 就是维持了一个全 局变量,所以它的 ORM 单元测试很难独立运行, 互相之间都会有影响。

个人看法:不到逼不得已,不要使用包变量。 所以我们需要考虑把注册中心交给一个东西来维护, 那么给谁呢?当然是 DB 模块了。

为什么说 DB 是最佳选择?

DB 在 ORM 中的地位,就相当于 HTTPServer 在 Web 框架中的地位。

  • 允许用户使用多个 DB 实例

  • 每个 DB 实例可以单独配置,例如配置元数据中心

  • DB 就是天然的隔离和治理单位

    • 例如超时配置

    • 方言:例如 MySQL DB 和 SQLite DB

    • 慢查询阈值

创建DB

type DBOption func(*DB)
​
type DB struct {
    r  *registry
}
​
func NewDB(opts ...DBOption) (*DB, error) {
    db := &DB{
        r: &registry{},
    }
    for _, opt := range opts {
        opt(db)
    }
    return db, nil
}

暂时设计一个 NewDB 的方法,并且留下了 Option 模式的口子,为将来留下扩展性的口子。后面支持更多功能的时候,会在 DB 里面不断添 加字段。

registry 定义

理论上来说,models 的 key 有三种选择:

  • 结构体名字(类型名字):例如 User。其实不太行,因为用户有同名结构体但是 表名不一样的需求,例如 buyer 包下面的 User 和 seller 包下面的 User。

  • 表名:例如 user_t。这个肯定不行,因为在拿到元数据之前我们都不知道表名是 什么。

  • reflect.Type:唯一的选择

type registry struct {
    // model key 是类型名
    // 这种定义方式是不行的
    // 1. 类型名冲突,例如都是 User,但是一个映射过去 buyer_t
    // 一个映射过去 seller_t
    // 2. 并发不安全
    // model map[string]*model
​
    lock   sync.RWMutex
    models map[reflect.Type]*Model
​
     使用 sync.Map
    //model sync.Map
}
  • models 字段:采用的是反射类型 => 元数据 的映射关系

  • get 方法:会首先查找 models,没有找到就会开 始解析,解析完放回去 models

// 使用读写锁的并发安全解决思路
func (r *registry) get(val any) (*Model, error) {
    if val == nil {
        return nil, errs.ErrInputNil
    }
    r.lock.RLock()
    typ := reflect.TypeOf(val)
    m, ok := r.models[typ]
    r.lock.RUnlock()
    if ok {
        return m, nil
    }
​
    r.lock.Lock()
    defer r.lock.Unlock()
    m, ok = r.models[typ]
    if ok {
        return m, nil
    }
    var err error
    if m, err = r.parseModel(typ); err != nil {
        return nil, err
    }
    r.models[typ] = m
    return m, nil
}

最后再一次改造 Selector;构造 SELECT 语句的时候从 registry 里面拿元数据。

// Selector 用于构造 SELECT 语句
type Selector[T any] struct {
    sb    strings.Builder
    args  []any
    table string
    where []Predicate
    model *model
​
    db *DB
}
​
func (s *Selector[T]) Build() (*Query, error) {
    var (
        t   T
        err error
    )
    s.model, err = s.db.r.get(&t)
    if err != nil {
        return nil, err
    }
    s.sb.WriteString("SELECT * FROM ")
    if s.table == "" {
        s.sb.WriteByte('`')
        s.sb.WriteString(s.model.tableName)
        s.sb.WriteByte('`')
    } else {
        s.sb.WriteString(s.table)
    }
​
    // 构造 WHERE
    if len(s.where) > 0 {
        // 类似这种可有可无的部分,都要在前面加一个空格
        s.sb.WriteString(" WHERE ")
        p := s.where[0]
        for i := 1; i < len(s.where); i++ {
            p = p.And(s.where[i])
        }
        if err := s.buildExpression(p); err != nil {
            return nil, err
        }
    }
    s.sb.WriteString(";")
    return &Query{
        SQL:  s.sb.String(),
        Args: s.args,
    }, nil
}

自定义表名和列名

目前我们的策略是驼峰转下划线命名,例如 FirstName 变成 first_name。 但是用户会有各种个性化的需求:

  • 自定义表名:比如说有些公司认为,User 结构体对 应的表名应该是 user_t

  • 自定义列名:比如说字段 Status,在 user_t 里面 可能就叫做 user_statu

先来看看 Beego 和 Gorm 是怎么做的

Beego orm

Beego orm 里面提供了两种方式:

  • 标签(Tag):用户可以在标签里面指定很多东 西,列名只是其中之一

  • 实现特定接口:例如实现了 TableNameI 就可以自 定义表名

这两种做法都有限制,比如它们对 protobuf 生成的结 构体,就不太好用。自己手写的结构体就没什么问 题。

Gorm

Gorm 和 Beego 差不多,都是标签和接口两种形态。

这里参考 beego 和 Grom 的策略 主要就是两个步骤: 定义标签的语法:这个完全就是个人偏好 解析标签:也就是利用反射提取到完整标 签,然后按照我们的需要进行切割

func (r *registry) parseTag(tag reflect.StructTag) (map[string]string, error) {
    ormTag := tag.Get("orm")
    if ormTag == "" {
        // 返回一个空的 map,这样调用者就不需要判断 nil 了
        return map[string]string{}, nil
    }
    // 这个初始化容量就是我们支持的 key 的数量,
    // 现在只有一个,所以我们初始化为 1
    res := make(map[string]string, 1)
​
    // 接下来就是字符串处理了
    pairs := strings.Split(ormTag, ",")
    for _, pair := range pairs {
        kv := strings.Split(pair, "=")
        if len(kv) != 2 {
            return nil, errs.NewErrInvalidTagContent(pair)
        }
        res[kv[0]] = kv[1]
    }
    return res, nil
}

接口自定义表名

支持标签有一个很尴尬的问题:

  • Go 的标签只支持声明在字段上,也就是说你无法 为类型定义标签

所以在采用标签方案的时候,意味着我们只能支持字段。结构体级别(或者说表级别),需要额外的手段。 很显然,可以参考 Beego 和 ORM 的做法。 相比原来, parseModel 多调用了一次 reflect.Type, 但是这一点点性能消耗,完全不用在意。

// TableName 用户实现这个接口来返回自定义的表名
type TableName interface {
    TableName() string
}
// parseModel 支持从标签中提取自定义设置
// 标签形式 orm:"key1=value1,key2=value2"
// 改为接收最原始的 val
func (r *registry) parseModel(val any) (*model, error) {  
    typ := reflect.TypeOf(val)
    if typ.Kind() != reflect.Ptr ||
        typ.Elem().Kind() != reflect.Struct {
        return nil, errs.ErrPointerOnly
    }
    typ = typ.Elem()
​
    // 获得字段的数量
    numField := typ.NumField()
    fds := make(map[string]*field, numField)
    for i := 0; i < numField; i++ {
        fdType := typ.Field(i)
        // 解析 tag
        tags, err := r.parseTag(fdType.Tag)
        if err != nil {
            return nil, err
        }
        colName := tags[tagKeyColumn]
        if colName == "" {
            colName = underscoreName(fdType.Name)
        }
        fds[fdType.Name] = &field{
            colName: colName,
        }
    }
    var tableName string
    // 判断是否实现了 TableName 接口
    if tn, ok := val.(TableName); ok {
        tableName = tn.TableName()
    }
​
    if tableName == "" {
        tableName = underscoreName(typ.Name())
    }
​
    return &model{
        tableName: tableName,
        fieldMap:  fds,
    }, nil
}

总结

  • ORM 框架是怎么将一个结构体映射为一张表的(或者反过来)?核心就是依赖于元数据,元数据描述 了两者之间的映射关系。

  • ORM 的元数据有什么用?在构造 SQL 的时候,用来将 Go 类型映射为表;在处理结果集的时候,用来 将表映射为 Go结构体。

  • ORM 的元数据一般包含什么?一般包含表信息、列信息、索引信息。在支持关联关系的时候,还包含 表之间的关联关系。

  • ORM 的表信息包含什么?主要就是表级别上的配置,例如表名。如果 ORM 本身支持分库分表,那么 还包含分库分表信息。

  • ORM 的列信息包含什么?主要就是列名、类型(和对应的 Go 类型)、索引、是否主键,以及关联关 系。

  • ORM 的索引信息包含什么?主要就是每一个索引的列,以及是否唯一。

  • ORM 如何获得模型信息?主要是利用反射来解析 Go 类型,同时可以利用 Tag,或者暴露编程接口, 允许用户额外定制模型(例如指定表名)。

  • Go 字段上的 Tag(标签)有什么用?用来描述字段本身的额外信息,例如使用 json 来指示转化 json 之后的字段名字,或者如 GORM 使用 Tag 来指定列的名字、索引等。这种问题可能出在面试官问 Go 语法上。

  • GORM(Beego) 是如何实现的?只要回答构造 SQL + 处理结果集 + 元数据就可以了。剩下的可能就 是进一步问 SQL 怎么构造,以及结果集是如何被处理的。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值