Go 中 ORM 的 Repository(仓储)模式

ORM 在业务开发中一直扮演着亦正亦邪的角色。很多人赞颂 ORM,认为 ORM 与面向对象的契合度让代码简洁有道。但是不少人厌恶它,因为 ORM 隐藏了太多的细节,埋下了超多的隐患。在 Go 中,我们也或多或少接触过 ORM,但是,在查阅不少业务代码后发现,ORM 使用起来颇为滑稽,并且“雷隐隐雾蒙蒙”。

从 Entity Framework 谈起

Entity Framework 作为雄踞 Microsoft .NET Framework 以及 .NET Core 的杀手级 ORM 不论在使用上还是效率上都是数一数二的。并且 Entity Framework 自带 Repository 模式(仓储模式)可以说降低了开发者的使用门槛。举几个实际的例子:

WebAppContext entity = new WebAppContext();

[HttpGet]
public ActionResult Index(String verify, String email)
{
    var databasemail = entity.Mails.Find(verify);
    //code...
    entity.Mails.Add(databasemail);
    entity.SaveChanges();
    //code...
}

可以看到,通过 Entity Framework 上下文,可以方便地检索到数据并在随后的使用中直接访问数据实体并按照直觉进行 CURD。

Go 里面的 ORM 是怎么做的呢?

Go 里面的 ORM 用法

下面的内容以 go-pg 为例。

Go 里对于 ORM 的用法就百花齐放了。一共见识过 4 种不同的用法:

Raw 查询式

Raw 查询实际上是很经典的使用方式,一般出报表、批量更新或者执行数据调整的脚本时非常有用,实际上新手刚刚接触到 Go,使用 ORM 也会倾向于使用 Raw 查询(简单)。所以滥用导致 Raw 查询实际上在代码中到处都是,几乎把 ORM 当作了数据库驱动在用。

func Query(sql string, params ...interface{}) ([]map[string]interface{}, error) {
    rows, err := DB.Raw(sql, params...).Rows()
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    list := []map[string]interface{}{}
    for rows.Next() {
        dest := make(map[string]interface{})
        if scanErr := MapScan(rows, dest); scanErr != nil {
            return nil, scanErr
        }
        list = append(list, dest)
    }
    return list, nil
}

这样做不是说不好,而是数据缺乏组织化,并且 []map[string]interface{} 这种东西在实际使用的时候很容易因为类型不具合翻车(panic)。所以 Raw 查询不是不好,而是滥用不好。一般使用 CTE、窗口函数之类的前置条件场景,使用 Raw 查询是合理的,但是需要注意对于 Raw 查询的复用:

func (service *DBService) cte(arg1, arg2 interface{}, domain ...interface{}) (sql string, args []interface{}) {
    //code...
    return
}

返回可以服用的 CTE 查询这样来降低雷同 Raw 查询出现的频次。

基础查询式

这种模式在 ORM 使用中相当常见。直接使用 ORM 传入模型然后执行检索,操作起来大约是这样的:

var entity = Entity{}
PostgreSQLConnection.Model(&entity).Where(`ID = $id`).Select()

看上去利用 ORM 的优势,就是查询出来的结果是一个结构化的实体,但实际上这样的模式实际上就是前面 Raw 查询模式的一个变种,不过相对更安全一些。这样的查询方式,利用 ORM 的模型映射,但是由于没有统一组织管理查询,使得整体看上去显得凌乱,也就是说,到处都是 PostgreSQLConnection.Model。并且,这样的模式与前面一样,无法在数据层面上完成逻辑表达。

数据层面的逻辑表达

例如,Corporation 实体实际上有 Staffs 的强关联数据,如果用这个模式,查询 Corporation.Staffs 应该去构建 Staff 模型,然后 WHERE 语句中添加 CORPORATION_ID 这样的参数信息。但是理论上我要查询到该企业的员工信息应该直接在该企业实体的 Staffs 属性或方法访问到才对。

当然,ORM 或提供改善这样的问题的能力。go-pg 提供一个关系数据引用检索的特性(但是这个特性 Issue 比较多...)来提供形如 .Staffs 的方法。不过需要在查询时显式声明检索,并且需要立即指定条件,最后拿到的 .Staffs 实际上是已经查出来的结果数据,灵活程度比较低(例如,只需要符合条件的 ID 列表)。

半仓储模式(或曰数据服务模式)

这个模式实际上是我之前用过的一种模式,这种模式将各类数据访问的逻辑封装起来成为一个数据服务:

type (
    //IService 服务契约定义
    IService interface {
        Save(*models.Entity) error
        Find(interface{}, ...func(*orm.Query)) (*models.Entity, error)
        Where(models.Entity, ...func(*orm.Query)) ([]models.Entity, error)
        Count(models.Entity, ...func(*orm.Query)) (int, error)
    }

    service struct {
        Pg    *pg.DB
    }
)

然后去实现对应的:

  • Save
  • Find
  • Where
  • Count

然后根据数据的逻辑关系添加其他的数据访问接口,例如 Corporation 的服务添加一个 Staffs 契约定义。

然后将这些服务集统一注册到服务对象:

type (
    //Services 基础服务集合
    Services struct {
        Corporation corporation.IService
    }
)

实际上这样的使用模式已经很接近终极形态了,虽然这样的模式已经构造了数据访问的统一入口,并且也尝试去解决数据层面的逻辑问题,但是这样的数据访问最大的问题是,换汤不换药:

service.Corporation.Staffs(corp, `ID IN (?)`, pg.In(array))

在上面的语句,看上去我通过 Corporation 的信息直接访问到了 Staffs,但是实际上对应的语义是:

用企业信息数据服务查询员工信息

而不是:

企业的员工信息

本质上没有解决前面两个的问题,大概就是农夫山泉和怡宝的区别。那么,像 Entity Framework 的仓储模式,Go 里怎么实现才能更加优雅呢?

仓储模式

我们不妨回到 Entity Framework 上下文声明:

namespace Tencent.Models
{
    public class WebAppContext : DbContext
    {
        public WebAppContext() : base("name=WebAppContext") {}
        public virtual DbSet<Entity> Entities { get; set; }
    }
}

注意到了吗,Entity.Entities 实际上并不是 Entity 类型而是 DbSet<T> 类型。为什么前面三个方法没有本质区别就在于,它们全是使用了 Plain Ordinary Go Structure(POGS)来推演数据以及提供数据的访问。

要做到仓储模式,我们应该构建数据库上下文结构(Go Structure with Database Context):

type (
    //Corporation 应用数据库模型
    Corporation struct {
        tableName struct{} `sql:"corporations"`
        *models.Corporation

        db *pg.DB
    }
)

//Save 保存
func (c *Corporation) Save() (err error) {
    if c.ID > 0 {
        err = c.db.Update(c)
    } else {
        err = c.db.Insert(c)
    }
    return
}

//Query 查询
func (c *Corporation) Query() (query *orm.Query) {
    return c.db.Model(c)
}

也就是与数据库交互,并在实际业务中流动的实例应该随附关联的数据库上下文。这样的话,可以在 Corporation 的实例方法中去定义 Staffs 方法:

//Staffs 公司员工列表
func (c *Corporation) Staffs(valid ...bool) *orm.Query {
    tables := c.db.Model((*User)(nil)).Where(`"corporation_id" = ?`, c.ID)
    if len(valid) > 0 {
        tables.Where(`"valid" IS ?`, valid[0])
    }
    q := c.db.Model().With("users", tables).Table("users")
    return q
}

注意,这里返回的是一个 CTE 查询。相当于 .Staffs() 方法并没有去直接执行查询而是提供一个“该公司员工数据集”的前置查询条件。如果需要查询关联员工信息的 ID,实际上还需要:

var staffIDs []int
err := corporation.Staffs().Column("id").Select(&staffIDs)

的后继查询操作。

为了实现统一的仓储模式,可以将这些结构统一注册到一个 Repositories:

type (
    //Service 数据库服务协议
    Repository interface {
        User(...*models.User) *User
        Corporation(...*models.Corporation) *Corporation
    }

    repository struct {
        *pg.DB
    }
)

//NewService 在目标连接上新建服务
func NewRepository(db *pg.DB) Repository {
    return &repository{db}
}

修改前面 Corporation 定义中的 db *pg.DBdb *repository,然后将 Corporation 的工厂方法注册到 Repository

//Corporation 企业数据库服务
func (service *service) Corporation(corp ...*models.Corporation) (entity *Corporation) {
    if len(corp) == 0 {
        corp = append(corp, nil)
    } else if corp[0] != nil {
        defer entity.Clean()
    }
    entity = &Corporation{Corporation: corp[0], db: service}
    return
}

至此,ORM with Repository in Go 就创建终了。Repository 模式有效隔离开了数据模型、数据库上下文模型,并且真的简化了 DB 访问的同时提供了数据层面的逻辑。如果业务中需要使用到 Go,还用到了 Go 的 ORM 来访问数据库,不妨借鉴 .NET 或 Java ORM 的做法。

这不大道至简。

本篇水文的前提是 ORM,都用 ORM 了谈什么大道至简。

相关推荐