Rafy 领域实体框架设计 - 重构 ORM 中的 Sql 生成

2023-05-19,,

前言


Rafy 领域实体框架作为一个使用领域驱动设计作为指导思想的开发框架,必然要处理领域实体到数据库表之间的映射,即包含了 ORM 的功能。由于在 09 年最初设计时,ORM 部分的设计并不是最重要的部分,那里 Rafy 的核心是产品线工程、模型驱动开发、界面生成等。所以当时,我们简单地采用了一个开源的小型 ORM 框架:《Lite ORM Library》。这个 ORM 框架可以生成比较简单的 Sql 语句,以处理一般性的情况。

随着不断使用,我们也不断对 ORM 的源码做了不少改动,让它在支持简单语句生成的同时,也支持让开发人员直接使用手动编写的 Sql 语句来查询领域实体。但是过程中,一直没有修改最核心的 Sql 语句生成模块。随着应用的不断深入,遇到的场景越来越多,需要生成复杂 Sql 语句的场景也越来越多。而这些场景如果还让开发人员自己去编写复杂 Sql 语句,不但框架的易用性下降,而且由于写了过多的 Sql 语句,还会让开发人员面向领域实体来开发的思想减弱。

这两周,我们对 Sql 语句生成模块实施了重构。与其说是重构,不如说重写,因为 90% Lite ORM 的类库都已经不再使用。但是又不得不面对对历史代码中接口的兼容性问题。接下来,将说明本次重构中的关键技术点。

旧代码讲解


最初采用的 Lite ORM 是一个轻量级的 ORM 框架,采用在实体对象上标记特性(Attribute)来声明实体的元数据,并使用链式接口来作为查询接口以方便开发人员使用。这是一个简单、易移植的 ORM 框架,对初次使用、设计 ORM 的同学来说,可以起到一个很好的借鉴作用。相关的设计,可以参考 Lite ORM 的原文章:《Lite ORM Library V2 》。

由于这几年我们已经对该框架做了大量的修改,所以很多接口已经与原框架不一致了。IQuery 作为描述查询的核心类型,被重命名为 IPropertyQuery,所有方法的参数也都直接面向 Rafy 实体的《托管属性》。但是在整体结构上,还是与原框架保持一致。例如,它还只是一个一维的结构:

   1:  /// <summary>
   2:  /// 使用托管属性进行查询的条件封装。
   3:  /// </summary>
   4:  public interface IPropertyQuery : IDirectlyConstrain
   5:  {
   6:      /// <summary>
   7:      /// 是否还没有任何语句
   8:      /// </summary>
   9:      bool IsEmpty { get; }
  10:   
  11:      /// <summary>
  12:      /// 当前的查询是一个分页查询,并使用这个对象来描述分页的信息。
  13:      /// </summary>
  14:      PagingInfo PagingInfo { get; }
  15:   
  16:      /// <summary>
  17:      /// 用于查询的 Where 条件。
  18:      /// </summary>
  19:      IConstraintGroup Where { get; set; }
  20:   
  21:      /// <summary>
  22:      /// 对引用属性指定的表使用关联查询
  23:      /// 
  24:      /// 调用此语句会生成相应的 INNER JOIN 语句,并把所有关联的数据在 SELECT 中加上。
  25:      /// 
  26:      /// 注意!!!
  27:      /// 目前不支持同时 Join 两个不同的引用属性,它们都引用同一个实体/表。
  28:      /// </summary>
  29:      /// <param name="property"></param>
  30:      /// <param name="type">是否同时查询出相关的实体数据。</param>
  31:      /// <param name="propertyOwner">
  32:      /// 显式指定该引用属性对应的拥有类型。
  33:      /// 一般使用在以下情况中:当引用属性定义在基类中,而当前正在对子类进行查询时。
  34:      /// </param>
  35:      /// <returns></returns>
  36:      IPropertyQuery JoinRef(IRefProperty property, JoinRefType type = JoinRefType.JoinOnly, Type propertyOwner = null);
  37:   
  38:      /// <summary>
  39:      /// 按照某个属性排序。
  40:      /// 
  41:      /// 可以调用此方法多次来指定排序的优先级。
  42:      /// </summary>
  43:      /// <param name="property">按照此属性排序</param>
  44:      /// <param name="direction">排序方向。</param>
  45:      /// <returns></returns>
  46:      IPropertyQuery OrderBy(IManagedProperty property, OrderDirection direction);
  47:   
  48:      //其它部分省略...
  49:  }

<!--
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, "Courier New", courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
-->

可以看到,该类型以一维的形式来描述了一个 Sql 查询的相关元素:Join 数据源、Where 条件、OrderBy 规则、分页信息。

只有其中的 Where 条件被设计为树型结构来处理相对复杂的 And、Or 连接的条件。

可以看到,虽然有 SqlWhereConstraint 来添加任意的 Sql 语句作为 Where 约束条件,但是这样的结构还是比较简单,不足以描述所有的 Sql。

重构方案


我们的目标是实现复杂 Sql 的生成,理论上需要支持所有能想到的 Sql 语句的生成。

初期方案其实很简单,就是使用解释器模式与访问器模式配合来重构底层代码。根据 Sql 的语法规定,构造 Sql 语法树节点中的相关类型,这样就可以用一棵树来解释任意的 Sql 语句;同时使用访问器模式来遍历某个具体 Sql 语法树。过程中还需要特别注意,尽量不要构造不必要的树节点,以增加垃圾回收器的压力。

在此初步方案上,还需要考虑:分层架构、组件间依赖、以及旧代码的兼容性设计。

以下是整个方案的分层设计:

SqlTree:核心的、可重用的 Sql 语法树层。定义了通用的 Sql 语法结构,并解决从语法树到 Sql 语句的转换、生成,以及屏蔽不同数据库间不同子句的生成规则。

EntityQuery:把 SqlTree 作为类库引用,同时整合领域实体、实体属性的设计。

Query Interface:以 IQuery 接口的方式提供给应用层。

Linq Query:为了给开发人员提供更易用的接口,需要提供 Linq 语法的支持。本层用于解析 Linq 表达式树,并生成最终的实体查询的对象。

Property Query:为了兼容旧的接口,该部分在提供旧接口的前提下,换为使用新的 IQuery 来实现。

Application:开发人员的应用层代码。可以使用最易用的 Linq、旧的 PropertyQuery,同时也可以直接使用 IQuery 接口来完成复杂查询。

组件详细设计


Sql 语法树

 

使用解释器模式设计,用于描述 Sql 查询语句。

所有树节点都从 SqlNode 继承,并拥有自己的属性来描述不同的节点位置。例如 SqlSelect 类型,代码如下:

   1:  /// <summary>
   2:  /// 表示一个 Sql 查询语句。
   3:  /// </summary>
   4:  class SqlSelect : SqlNode
   5:  {
   6:      private IList _orderBy;
   7:   
   8:      public override SqlNodeType NodeType
   9:      {
  10:          get { return SqlNodeType.SqlSelect; }
  11:      }
  12:   
  13:      /// <summary>
  14:      /// 是否只查询数据的条数。
  15:      /// 
  16:      /// 如果这个属性为真,那么不再需要使用 Selection。
  17:      /// </summary>
  18:      public bool IsCounting { get; set; }
  19:   
  20:      /// <summary>
  21:      /// 是否需要查询不同的结果。
  22:      /// </summary>
  23:      public bool IsDistinct { get; set; }
  24:   
  25:      /// <summary>
  26:      /// 如果指定此属性,表示需要查询的条数。
  27:      /// </summary>
  28:      public int? Top { get; set; }
  29:   
  30:      /// <summary>
  31:      /// 要查询的内容。
  32:      /// 如果本属性为空,表示要查询所有列。
  33:      /// </summary>
  34:      public SqlNode Selection { get; set; }
  35:   
  36:      /// <summary>
  37:      /// 要查询的数据源。
  38:      /// </summary>
  39:      public SqlSource From { get; set; }
  40:   
  41:      /// <summary>
  42:      /// 查询的过滤条件。
  43:      /// </summary>
  44:      public SqlConstraint Where { get; set; }
  45:   
  46:      /// <summary>
  47:      /// 查询的排序规则。
  48:      /// 可以指定多个排序条件,其中每一项都必须是一个 SqlOrderBy 对象。
  49:      /// </summary>
  50:      public IList OrderBy
  51:      {
  52:          get
  53:          {
  54:              if (_orderBy == null)
  55:              {
  56:                  _orderBy = new ArrayList();
  57:              }
  58:              return _orderBy;
  59:          }
  60:          internal set { _orderBy = value; }
  61:      }
  62:   
  63:      //...
  64:  }

<!--
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, "Courier New", courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
-->

Sql 生成器

 

使用访问器模式设计,用于遍历整个 Sql 语法树。以下是 SqlNodeVisitor 的代码:

   1:  /// <summary>
   2:  /// SqlNode 语法树的访问器
   3:  /// </summary>
   4:  abstract class SqlNodeVisitor
   5:  {
   6:      protected SqlNode Visit(SqlNode node)
   7:      {
   8:          switch (node.NodeType)
   9:          {
  10:              case SqlNodeType.SqlLiteral:
  11:                  return this.VisitSqlLiteral(node as SqlLiteral);
  12:              case SqlNodeType.SqlSelect:
  13:                  return this.VisitSqlSelect(node as SqlSelect);
  14:              case SqlNodeType.SqlColumn:
  15:                  return this.VisitSqlColumn(node as SqlColumn);
  16:              case SqlNodeType.SqlTable:
  17:                  return this.VisitSqlTable(node as SqlTable);
  18:              case SqlNodeType.SqlColumnConstraint:
  19:                  return this.VisitSqlColumnConstraint(node as SqlColumnConstraint);
  20:              case SqlNodeType.SqlBinaryConstraint:
  21:                  return this.VisitSqlBinaryConstraint(node as SqlBinaryConstraint);
  22:              case SqlNodeType.SqlJoin:
  23:                  return this.VisitSqlJoin(node as SqlJoin);
  24:              case SqlNodeType.SqlArray:
  25:                  return this.VisitSqlArray(node as SqlArray);
  26:              case SqlNodeType.SqlSelectAll:
  27:                  return this.VisitSqlSelectAll(node as SqlSelectAll);
  28:              case SqlNodeType.SqlColumnsComparisonConstraint:
  29:                  return this.VisitSqlColumnsComparisonConstraint(node as SqlColumnsComparisonConstraint);
  30:              case SqlNodeType.SqlExistsConstraint:
  31:                  return this.VisitSqlExistsConstraint(node as SqlExistsConstraint);
  32:              case SqlNodeType.SqlNotConstraint:
  33:                  return this.VisitSqlNotConstraint(node as SqlNotConstraint);
  34:              case SqlNodeType.SqlSubSelect:
  35:                  return this.VisitSqlSubSelect(node as SqlSubSelect);
  36:              default:
  37:                  break;
  38:          }
  39:          throw new NotImplementedException();
  40:      }
  41:   
  42:      protected virtual SqlJoin VisitSqlJoin(SqlJoin sqlJoin)
  43:      {
  44:          this.Visit(sqlJoin.Left);
  45:          this.Visit(sqlJoin.Right);
  46:          this.Visit(sqlJoin.Condition);
  47:          return sqlJoin;
  48:      }
  49:   
  50:      protected virtual SqlBinaryConstraint VisitSqlBinaryConstraint(SqlBinaryConstraint node)
  51:      {
  52:          this.Visit(node.Left);
  53:          this.Visit(node.Right);
  54:          return node;
  55:      }
  56:   
  57:      //...
  58:  }

<!--
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, "Courier New", courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
-->

基于实体的查询

1. IQuery 相关接口用于描述整个基于实体的查询。

例如,IColumnNode 表示一个列节点,其实是由一个实体属性来指定的:

   1:  namespace Rafy.Domain.ORM.Query
   2:  {
   3:      /// <summary>
   4:      /// 一个列节点
   5:      /// </summary>
   6:      public interface IColumnNode : IQueryNode
   7:      {
   8:          /// <summary>
   9:          /// 本列属于指定的数据源
  10:          /// </summary>
  11:          INamedSource Owner { get; set; }
  12:   
  13:          /// <summary>
  14:          /// 本属性对应一个实体的托管属性
  15:          /// </summary>
  16:          IManagedProperty Property { get; set; }
  17:   
  18:          /// <summary>
  19:          /// 本属性在查询结果中使用的别名。
  20:          /// </summary>
  21:          string Alias { get; set; }
  22:      }
  23:  }

<!--
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, "Courier New", courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
-->

2. EntityQuery 层中的类型实现了 IQuery 中对应的接口,并使用领域实体的相关 API 来实现从实体到表、实体属性到列的转换。同时,为了减少对象的数量,这些类型与 Sql 语法树的关系都使用继承,而不是关联。也就是说,它们直接从 SqlTree 对应的类型上继承下来,这样,在构造 EntityQuery 的同时,也构造好了底层的 Sql 语法树。

3. QueryFactory 封装了大量易用的 API 来构造 IQuery 接口。

使用示例


下面,就以几个典型的单元测试的相关代码来说明新的查询框架的使用方法:

使用 Linq 的数据层查询

   1:  public int LinqCountByBookName(string name)
   2:  {
   3:      return this.FetchCount(r => r.DA_LinqCountByBookName(name));
   4:  }
   5:  private EntityList DA_LinqCountByBookName(string name)
   6:  {
   7:      var q = this.CreateLinqQuery();
   8:      q = q.Where(c => c.Book.Name == name);
   9:      return this.QueryList(q);
  10:  }

<!--
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, "Courier New", courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
-->

使用 IQuery 的数据层查询

   1:  public int CountByBookName2(string name)
   2:  {
   3:      return this.FetchCount(r => r.DA_CountByBookName2(name));
   4:  }
   5:  private EntityList DA_CountByBookName2(string name)
   6:  {
   7:      var source = f.Table(this);
   8:      var bookSource = f.Table<BookRepository>();
   9:      var q = f.Query(
  10:          from: f.Join(source, bookSource)
  11:      );
  12:      q.AddConstraintIf(Book.NameProperty, PropertyOperator.Equal, name);
  13:      return this.QueryList(q);
  14:  }

<!--
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, "Courier New", courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
-->

可以看到,使用 IQuery 接口来查询,虽然灵活性最大、性能更好,但是相对于 Linq 来说会更加复杂。

使用 IQuery 来生成 Sql

   1:  [TestMethod]
   2:  public void ORM_TableQuery_InSubSelect()
   3:  {
   4:      var f = QueryFactory.Instance;
   5:      var articleSource = f.Table(RF.Concrete<ArticleRepository>());
   6:      var userSource = f.Table(RF.Concrete<BlogUserRepository>());
   7:      var query = f.Query(
   8:          from: userSource,
   9:          where: f.Constraint(
  10:              column: userSource.Column(BlogUser.IdProperty),
  11:              op: PropertyOperator.In,
  12:              value: f.Query(
  13:                  selection: articleSource.Column(Article.UserIdProperty),
  14:                  from: articleSource,
  15:                  where: f.Constraint(articleSource.Column(Article.CreateDateProperty), DateTime.Today)
  16:              )
  17:          )
  18:      );
  19:   
  20:      var generator = new SqlServerSqlGenerator { AutoQuota = false };
  21:      f.Generate(generator, query);
  22:      var sql = generator.Sql;
  23:   
  24:      Assert.IsTrue(sql.ToString() ==
  25:  @"SELECT *
  26:  FROM BlogUser
  27:  WHERE BlogUser.Id IN (
  28:      SELECT Article.UserId
  29:      FROM Article
  30:      WHERE Article.CreateDate = {0}
  31:  )");
  32:      Assert.IsTrue(sql.Parameters.Count == 1);
  33:      Assert.IsTrue(sql.Parameters[0].Equals(DateTime.Today));
  34:  }

<!--
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, "Courier New", courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
-->

使用 SqlTree 来生成 Sql

   1:  [TestMethod]
   2:  public void ORM_SqlTree_Select_InSubSelect()
   3:  {
   4:      var select = new SqlSelect();
   5:      var articleTable = new SqlTable { TableName = "Article" };
   6:      var subSelect = new SqlSelect
   7:      {
   8:          Selection = new SqlColumn { Table = articleTable, ColumnName = "UserId" },
   9:          From = articleTable,
  10:          Where = new SqlColumnConstraint
  11:          {
  12:              Column = new SqlColumn { Table = articleTable, ColumnName = "CreateDate" },
  13:              Operator = SqlColumnConstraintOperator.Equal,
  14:              Value = DateTime.Today
  15:          }
  16:      };
  17:   
  18:      var userTable = new SqlTable { TableName = "User" };
  19:      select.Selection = new SqlSelectAll();
  20:      select.From = userTable;
  21:      select.Where = new SqlColumnConstraint
  22:      {
  23:          Column = new SqlColumn { Table = userTable, ColumnName = "Id" },
  24:          Operator = SqlColumnConstraintOperator.In,
  25:          Value = subSelect
  26:      };
  27:   
  28:      var generator = new SqlServerSqlGenerator { AutoQuota = false };
  29:      generator.Generate(select);
  30:      var sql = generator.Sql;
  31:      Assert.IsTrue(sql.ToString() == @"SELECT *
  32:  FROM User
  33:  WHERE User.Id IN (
  34:      SELECT Article.UserId
  35:      FROM Article
  36:      WHERE Article.CreateDate = {0}
  37:  )");
  38:      Assert.IsTrue(sql.Parameters.Count == 1);
  39:      Assert.IsTrue(sql.Parameters[0].Equals(DateTime.Today));
  40:  }

<!--
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, "Courier New", courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
-->

框架下载


框架使用测试驱动的方法开发,在开发时是先编写相关的测试用例,再实现内部代码。重构的同时,我们为能想到的场景都编写了测试用例:

目前,框架版本也升级到了 2.23.2155。

有兴趣的同学,了解、下载最新的框架,请参考:《Rafy 领域实体框架发布!》。(框架目前不开源,但可免费使用。)

Rafy 领域实体框架设计 - 重构 ORM 中的 Sql 生成的相关教程结束。

《Rafy 领域实体框架设计 - 重构 ORM 中的 Sql 生成.doc》

下载本文的Word格式文档,以方便收藏与打印。