自适应分层知识管理 - 第一部分





5.00/5 (9投票s)
我正在着手编写一个实际的产品,它将执行“知识管理”的任务。
目录
引言
我对软件开发的一些方面特别热爱
- 数据 - 从抽象意义上讲,它是什么,它的属性是什么,为什么存在?
- 数据关系 - 离散数据如果没有与其他数据的关系上下文,就没有太多意义。
- 架构 - 主要是因为这是一门失传的艺术,就像一件艺术品一样,一个设计精良的产品可以是美轮美奂的。
- 用户界面 - 创建一个实际可用的用户界面是另一门失传的艺术。
- 抽象 - 程序实现得越具体,灵活性就越差。灵活性越强,配置起来就越复杂。平衡点在哪里?
我即将着手编写一个实际的产品,它将执行“知识管理”的任务。正如 Bloomfire(知识管理应用程序提供商,我与它没有关联)所述:
“知识管理 (KM) 更加多维,能够在一个单一平台上搜索、捕获、更新和维护相关信息。任何人都可以实时整理或更新信息,从而激发同事和部门之间的对话和协作。将知识管理视为内容管理的一个子集,专门从事知识分发。”
我将知识管理视为许多不同事物的总称
- 文档管理
- 客户关系管理 (CRM)
- 会计
- 项目管理
- 政府(范围很广,从警务报告到市政管理,再到州和联邦项目)
- 工程
- 记录管理(警察、消防、紧急情况等)
- 健康服务
以及一些虽然有点傻但很有用的东西,比如
- 联系人管理
- 食谱
- 相册
- 音乐专辑
- 购物清单
以及社区/协作方面的东西,比如
- 拼车板
- 需求与馈赠
- 协调会议安排
- 研究
此类列表不胜枚举。
而这些最终都变成了
- 定制应用程序开发,其范围要么足够广阔,成为一个利基产品,要么仍然是一个内部工具产品。
- 针对特定利基市场的通用工具(例如 Atlassian / JIRA、SalesForce、OnBase、DocuShare 等)。
这里的目标不是创建一个“低代码”或“无代码”应用程序构建器,而是创建一个可以构建其他产品的基础产品。我将在这些文章中详细描述我正在撰写的内容:“一个自适应分层知识管理元模型。”
要求
- Visual Studio 2019 或类似版本
- .NET Core 3.0
- TypeScript 4.2 版或更高版本(来自 NuGet)
如果您尝试使用代码,则必须创建表(请参阅下面的“架构”部分)并编辑 appsetting.json 以获取您自己的数据库连接字符串。
背景
除了鉴于我所从事的行业和多年的软件开发经验(好的、坏的、丑陋的)而觉得自己有资格撰写此主题外,我还撰写了一些关于这些概念的文章
架构
这是最终产品外观的华丽图片(点击此处查看全尺寸图片)
一图胜千言,但也可能引出千个问题。所以,请欣赏这幅图,我将逐步解释各个部分。请注意,随着后续文章中实现的进展,这个总体的图表可能会发生变化。
入门
第一步可能是最具争议性的一步。我们不是使用离散的表及其关联的列来持久化数据,而是要实现一个“数据库中的数据库”。当然,像 NoSql 这样的非关系型数据库可能很适合这种方法,但目前我将把所有内容都实现在 SQL Server 中。想法是这样的——给定
我们有一种通用的方式来描述任何实体及其字段——是的,就像我们在 SQL 中描述一个表及其列一样。但在这种实现中,存在一些 immediate concerns。
关注点
- 性能
- 实体字段值的每个实例都将存储在“
EntityFieldValue
”表中。 - 数据类型 - 我们需要一个通用的值类型,从而失去数据值的实际类型,这会影响搜索性能
- 实体字段值的每个实例都将存储在“
- 行与列 - 每条记录的数据现在以行而不是列表示。
- 编写基本查询更加困难,因为需要进行透视操作,并且搜索需要进行类型转换。
正如一位评论者在 Stack Overflow 上评论的那样
“将不同类型的数据存储到列中的数据库设计几乎总会在以后成为一个主要问题。数据质量几乎总是会受到影响。我见过这导致非原子数据(逗号分隔值)、字符串字段中格式错误的日期值、单列的多用途以及许多其他问题。我建议您研究他们想要存储什么,并使用正确规范化的表进行适当的数据库设计。”
解决方案
这些解决方案不一定是理想的,但它们在很大程度上解决了上述问题。
- 对“
EntityFieldValue
”表进行适当的索引。 - 将 SQL_Variant 类型用于值列——这实际上是一个非常了不起的功能。
- 包装查询,使其自动执行透视操作(不确定这与 EF 如何协同工作)
早期实施决策
实体框架
这种方法几乎不适合实体框架(EF)或任何对象关系映射器,因为我们实际上并未处理具体的表及其各自的模型,元数据管理表除外。即使我们确实使用了像 EF 这样的 ORM,又有什么意义呢?所有与数据库的交互都可以直接处理——无论是返回表记录还是多条记录,而无需经过模型的序列化/反序列化过程,或者通过直接使用 POST
和 PATCH
调用的 JSON 正文进行处理。这让我想到第二点——JSON 序列化器。
System.Text.Json
尽管声称这是一种性能更高的序列化器/反序列化器,但将 JSON 反序列化为任意的键值字典会导致值成为 JsonElement
。此类别要求您要么处理原始文本,要么使用 Get[SomeType]
方法之一,例如 GetInt32()
、GetDecimal()
、GetDateTime()
等,这违背了通用 REST API 的目的。
NewtonSoft
因此,实现决策是使用 NewtonSoft,因为 JSON 正文可以直接反序列化为 Dictionary<string, object>
表示,例如
public object Insert(string entity, Dictionary<string, object> data)
Dapper
Dapper 在数据库和 REST API 之间提供了一个有用的接口,而无需支持实体。此外,我们可以直接将 Dictionary<string, object>
作为参数传递给 Dapper。Dapper 的缺点是我们必须为 CRUD 操作生成 SQL,因为 Dapper 不是一个成熟的 ORM,而更像是核心 ADO.NET 方法的包装器,但它足够智能,以至于我不想编写自己的 ADO.NET 包装器。虽然它也可以与 C# 模型一起工作,但 Dapper 在此应用程序中的大部分用法是无模型的。
表指南
每个表都会有
- 主键列
ID
- 一个位类型
Deleted
列,表示记录已被删除。我们从不物理删除记录。
我们依靠此来以一致的方式生成 SQL。
初始架构
初始模式真的非常精简——我还没有在这些表中添加任何“智能”。
以下是初始模式
实体
CREATE TABLE [dbo].[Entity](
[ID] [int] IDENTITY(1,1) NOT NULL,
[Name] [nvarchar](255) NOT NULL,
[Deleted] [bit] NOT NULL,
CONSTRAINT [PK_Entity] PRIMARY KEY CLUSTERED ...
EntityField
CREATE TABLE [dbo].[EntityField](
[ID] [int] IDENTITY(1,1) NOT NULL,
[Name] [nvarchar](255) NOT NULL,
[EntityID] [int] NOT NULL,
[Deleted] [bit] NOT NULL,
CONSTRAINT [PK_EntityField] PRIMARY KEY CLUSTERED ...
ALTER TABLE [dbo].[EntityField] WITH CHECK ADD CONSTRAINT [FK_Entity_EntityField] _
FOREIGN KEY([EntityID])
REFERENCES [dbo].[Entity] ([ID])
EntityInstance
CREATE TABLE [dbo].[EntityInstance](
[ID] [int] IDENTITY(1,1) NOT NULL,
[EntityID] [int] NOT NULL,
[Deleted] [bit] NOT NULL,
CONSTRAINT [PK_EntityInstance] PRIMARY KEY CLUSTERED
ALTER TABLE [dbo].[EntityInstance] _
WITH CHECK ADD CONSTRAINT [FK_EntityInstance_Entity] FOREIGN KEY([EntityID])
REFERENCES [dbo].[Entity] ([ID])
EntityFieldValue
CREATE TABLE [dbo].[EntityFieldValue](
[ID] [int] IDENTITY(1,1) NOT NULL,
[EntityInstanceID] [int] NOT NULL,
[EntityID] [int] NOT NULL,
[EntityFieldID] [int] NOT NULL,
[Value] [sql_variant] NULL,
[Deleted] [bit] NOT NULL,
CONSTRAINT [PK_EntityFieldValue] PRIMARY KEY CLUSTERED ...
ALTER TABLE [dbo].[EntityFieldValue] WITH CHECK ADD CONSTRAINT _
[FK_EntityFieldValue_EntityInstance] FOREIGN KEY([EntityInstanceID])
REFERENCES [dbo].[EntityInstance] ([ID])
ALTER TABLE [dbo].[EntityFieldValue] WITH CHECK ADD CONSTRAINT _
[FK_EntityFieldValue_Entity] FOREIGN KEY([EntityID])
REFERENCES [dbo].[Entity] ([ID])
ALTER TABLE [dbo].[EntityFieldValue] WITH CHECK ADD CONSTRAINT _
[FK_EntityFieldValue_EntityField] FOREIGN KEY([EntityFieldID])
REFERENCES [dbo].[EntityField] ([ID])
实现
SysAdmin 控制器
我们首先需要一个系统管理员控制器,它可以处理 Entity
和 EntityField
表,以便我们开始描述实体的元数据。
[ApiController]
[Route("[controller]")]
public class SysAdminController : ControllerBase
{
private IConfiguration cfg;
private TableService ts;
public SysAdminController(IConfiguration configuration, TableService ts)
{
cfg = configuration;
this.ts = ts;
}
[HttpGet("{entity}")]
public object GetEntities(string entity)
{
var data = ts.GetAll(cfg, entity);
return data;
}
[HttpGet("{entity}/{entityid}")]
public object GetEntity(string entity, int entityid)
{
var data = ts.GetSingle(cfg, entity, entityid);
return data;
}
[HttpPost("{entity}")]
public object Insert(string entity, Dictionary<string, object> data)
{
var newRecord = ts.Insert(cfg, entity, data);
return newRecord;
}
[HttpPatch("{entity}/{entityid}")]
public object Update(string entity, int entityid, Dictionary<string, object> data)
{
var record = ts.Update(cfg, entity, entityid, data);
return record;
}
[HttpDelete("{entity}/{entityid}")]
public object Delete(string entity, int entityid)
{
ts.Delete(cfg, entity, entityid);
return NoContent();
}
}
这实现了对我们“系统
”表的 CRUD 操作,映射到 GET
、POST
、PATCH
和 DELETE
操作。目前,这些端点将对任何遵循上述指南的表进行操作。
角色
稍后,我将添加角色验证,以验证用户确实是系统管理员,并且正在操作的表确实是元模型中的“系统”表。
TableService
此服务为通用 CRUD 操作提供 SQL 生成。这是应用程序的关键组件!它旨在提供通用服务,用于操作表而无需任何支持模型。
请注意,由 Dapper 的 Query
方法返回的 List<DapperRow>
被转换为 List<IDictionary<string, object>>
,后者由 DapperRow
实现。由于 DapperRow
是密封的,因此此转换是必要的,以便我们(如有必要)可以操作集合或单个记录。
公共方法
为了可读性,这些类型被别名化
using Record = System.Collections.Generic.IDictionary<string, object>;
using Records = System.Collections.Generic.List
<System.Collections.Generic.IDictionary<string, object>>;
using Parameters = System.Collections.Generic.Dictionary<string, object>;
public
方法实现 CRUD 操作
/// <summary>
/// Returns the DapperRow collection as a collection of IDictionary string-object pairs.
/// </summary>
public Records GetAll(IConfiguration cfg, string tableName, Conditions where = null)
{
var ret = Query(cfg, tableName, null, QueryFnc, where).Cast<Record>().ToList();
return ret;
}
/// <summary>
/// Returns the DapperRow as IDictionary string-object pairs.
/// </summary>
public Record GetSingle(IConfiguration cfg, string tableName, int recordId)
{
var ret = Query(cfg, tableName, recordId, QueryFnc).Cast<Record>().SingleOrDefault();
return ret;
}
/// <summary>
/// Returns the DapperRow as IDictionary string-object pairs.
/// </summary>
public Record Insert(IConfiguration cfg, string tableName, Parameters parms)
{
// Returns the full record.
var ret = Insert(cfg, tableName, parms, QueryFnc).SingleOrDefault();
return ret;
}
/// <summary>
/// Returns the DapperRow as IDictionary string-object pairs.
/// </summary>
public Record Update(IConfiguration cfg, string tableName, int id, Parameters parms)
{
// Returns the full record.
var ret = Update(cfg, tableName, id, parms, QueryFnc).SingleOrDefault();
return ret;
}
public void Delete(IConfiguration cfg, string tableName, int id)
{
var parms = new Parameters() { { "Deleted", true } };
Update(cfg, tableName, id, parms);
}
除了 Delete
方法,每个方法都返回
- 对于查询,当提供记录 ID 时,返回记录集合或单个记录。
- 对于
insert
操作,返回新插入的记录。 - 对于
update
操作,返回更新后的记录。
幕后
查询操作是一个作为参数传递给 Read
、Update
和 Create
操作的方法,并执行实际的 Dapper 调用
private Records QueryFnc(SqlConnection conn, (string sql, Parameters parms) qinfo)
{
var records = conn.Query(qinfo.sql, qinfo.parms).Cast<Record>().ToList();
return records;
}
TableService
的核心实际上是 SQL 生成。我们首先有一个中间层,根据操作是 query
、insert
还是 update
来确定 SQL。因为 delete
操作实际上是 update
(我们将 Deleted
标志设置为 true),所以没有物理删除记录的实现。这些方法中的每一个都创建 SqlConnection
并执行作为参数传递的函数。请注意
private Records Query(IConfiguration cfg, string tableName,
int? id, Func<SqlConnection, (string sql, Parameters parms), Records> query)
{
var cs = cfg.GetConnectionString("DefaultConnection");
using (var conn = new SqlConnection(cs))
{
var qinfo = SqlSelectBuilder(tableName, id);
var ret = query(conn, qinfo).Cast<Record>().ToList();
return ret;
}
}
private Records Insert(IConfiguration cfg, string tableName,
Parameters parms, Func<SqlConnection, (string sql, Parameters parms), Records> query)
{
var cs = cfg.GetConnectionString("DefaultConnection");
using (var conn = new SqlConnection(cs))
{
var qinfo = SqlInsertBuilder(tableName, parms);
var ret = query(conn, qinfo).Cast<Record>().ToList();
return ret;
}
}
private Records Update(IConfiguration cfg, string tableName, int id,
Parameters parms, Func<SqlConnection, (string sql, Parameters parms), Records> query)
{
var cs = cfg.GetConnectionString("DefaultConnection");
using (var conn = new SqlConnection(cs))
{
var qinfo = SqlUpdateBuilder(tableName, id, parms);
var ret = query(conn, qinfo).Cast<Record>().ToList();
return ret;
}
}
最后,我们有实际生成 SQL 并根据需要操作我们传递给 Dapper 的参数的方法。
请注意,当前的实现只是一个 select *
,这不一定是我们想要的。我将在以后(未来的文章!)讨论请求特定字段的问题,届时我们将研究更通用的查询。请注意,SQL“select
”构建器有两个版本——一个用于直接查询,一个用于在插入记录后选择该记录。
private (string sql, Parameters parms) SqlSelectBuilder(string table, int? id)
{
StringBuilder sb = new StringBuilder();
sb.Append($"select * from {table} where Deleted = 0");
var parms = new Parameters();
if (id != null)
{
sb.Append(" and id = @id");
parms.Add("id", id.Value);
}
return (sb.ToString(), parms);
}
private (string sql, Parameters parms) SqlInsertSelectBuilder(string table)
{
StringBuilder sb = new StringBuilder();
sb.Append($"select * from {table} where Deleted = 0");
sb.Append(" and id in (SELECT CAST(SCOPE_IDENTITY() AS INT))");
var parms = new Parameters();
return (sb.ToString(), parms);
}
private (string sql, Dictionary<string, object> parms) SqlInsertBuilder
(string table, Dictionary<string, object> parms)
{
parms["Deleted"] = false;
var cols = String.Join(", ", parms.Keys.Select(k => k));
var vals = String.Join(", ", parms.Keys.Select(k => $"@{k}"));
StringBuilder sb = new StringBuilder();
sb.Append($"insert into {table} ({cols}) values ({vals});");
var query = SqlInsertSelectBuilder(table).sql;
sb.Append(query);
return (sb.ToString(), parms);
}
private (string sql, Dictionary<string, object> parms) SqlUpdateBuilder
(string table, int id, Dictionary<string, object> parms)
{
var setters = String.Join(", ", parms.Keys.Select(k=>$"{k}=@{k}"));
StringBuilder sb = new StringBuilder();
sb.Append($"update {table} set {setters} where id = @id;");
var query = SqlSelectBuilder(table, id).sql;
sb.Append(query);
// Add at end, as we're not setting the ID.
parms["id"] = id;
return (sb.ToString(), parms);
}
请注意 insert
操作如何为 scopeIdentity
参数传入 true
,这样 SQL 就被正确地构造,以便在插入记录后将其读回。这样做的主要原因(并非双关语)是为了返回新创建记录的主键 ID
。
使用示例
我们可以使用 Postman 或类似工具创建一个简单的实体“Name
”
curl --location --request POST 'https://:8493/sysadmin/entity' \
--header 'Content-Type: application/json' \
--data-raw '{"Name": "Name"}'
返回
{
"Name": "Name",
"Id": 5,
"Deleted": false
}
它有两个字段,“FirstName
”和“LastName
”
curl --location --request POST 'https://:8493/sysadmin/entityfield' \
--header 'Content-Type: application/json' \
--data-raw '{"Name": "FirstName", "EntityID": 4}'
curl --location --request POST 'https://:8493/sysadmin/entityfield' \
--header 'Content-Type: application/json' \
--data-raw '{"Name": "LastName", "EntityID": 4}'
分别返回
{
"Name": "FirstName",
"EntityId": 4,
"Id": 1,
"Deleted": false
}
{
"Name": "LastName",
"EntityId": 4,
"Id": 2,
"Deleted": false
}
然后我们可以通过简单的 GET
请求查看这些实体
sysadmin/entity
返回
[
{
"ID":4,
"Name":"Name",
"Deleted":false
}
]
和
sysadmin/entityfield
返回
[
{
"ID":1,
"Name":"FirstName",
"EntityID":4,
"Deleted":false
},
{
"ID":2,
"Name":"LastName",
"EntityID":4,
"Deleted":false
}
]
或者,例如,通过指定记录 ID
sysadmin/entityfield/2
我们看到
{
"ID":2,
"Name":"LastName",
"EntityID":4,
"Deleted":false
}
返回 Deleted
标志有点烦人,但目前我不打算处理它。因为这些是软删除,所以查看已删除的记录实际上可能很有用,所以暂时我会保留它。
模型控制器
简单模型控制器背后的想法是
- 仅支持查询操作。
- 此控制器中的 API 对任何授权用户都可访问。
- 实体查询与相应的实体字段进行整理,因此您将获得实体及其字段的层次结构。
端点实现
在实现中,请注意几点
- 我添加了一个
Conditions
功能。 GetAll
方法现在具有where
子句的概念,尽管非常基础。- 有一个名为
Collate
的新方法,用于向父表添加层次结构。 - 稍后,我们还会将此“
条件
”功能扩展到GetSingle
方法。
[HttpGet("entity")]
public object GetEntities()
{
var entities = ts.GetAll(cfg, "Entity");
var entityFields = ts.GetAll(cfg, "EntityField");
ts.Collate(entities, entityFields, "ID", "EntityID", "EntityFields");
entities;
return entities;
}
[HttpGet("entity/{entityid}")]
public object GetEntity(int entityid)
{
var entities = ts.GetAll(cfg, "Entity", where: new Conditions()
{ new Condition("ID", Condition.Op.Equals, entityid) });
var entityFields = ts.GetAll(cfg, "EntityField",
where: new Conditions() { new Condition("EntityID", Condition.Op.Equals, entityid) });
ts.Collate(entities, entityFields, "ID", "EntityID", "EntityFields");
var entity = entities.SingleOrDefault();
return entity;
}
Condition 类
现在,我们可以利用以下辅助类开始使用条件子句或 where
子句。我没有直接显式编码 where
子句的原因是我认为这是一种糟糕的形式。虽然我可能会实现一个名为“explicit
”的运算符,但这里的想法是能够以编程方式创建条件,然后让 Conditions
集合决定如何形成 SQL。
public class Condition
{
public enum Op
{
Equals,
NotEqual,
IsNull,
IsNotNull,
}
public string Field { get; set; }
public Op Operation { get; set; }
public object Value { get; set; }
public Condition(string field, Op operation)
{
Field = field;
Operation = operation;
}
public Condition(string field, Op operation, object val)
{
Field = field;
Operation = operation;
Value = val;
}
}
Conditions 类
此类别派生自 List<Condition>
并实现 SQL 生成,并尝试使其成为模板驱动。我实现了一些操作作为示例。
public class Conditions : List<Condition>
{
private static Dictionary<Condition.Op, string> opTemplate =
new Dictionary<Condition.Op, string>()
{
{Condition.Op.Equals, "[Field] = [FieldParam]" },
{Condition.Op.NotEqual, "[Field] != [FieldParam]" },
{Condition.Op.IsNull, "[Field] is null" },
{Condition.Op.IsNotNull, "[Field] is not null" },
};
public void AddConditions(StringBuilder sb, Dictionary<string, object> parms)
{
if (this.Count > 0)
{
List<string> conditions = new List<string>();
this.ForEach(c =>
{
var parmName = $"p{c.Field}";
var template = opTemplate[c.Operation];
template = template
.Replace("[Field]", c.Field)
.Replace("[FieldParam]", $"@{parmName}");
// Add the parameter regardless of whether we use it or not.
parms[parmName] = c.Value;
conditions.Add(template);
});
var anders = string.Join(" and ", conditions);
sb.Append($" and {anders}");
}
}
}
最后,在 SqlSelectBuilder
中,我们添加一个调用以将条件 SQL 添加到 where
子句
private (string sql, Parameters parms) SqlSelectBuilder
(string table, int? id, Conditions where = null)
{
StringBuilder sb = new StringBuilder();
sb.Append($"select * from {table} where Deleted = 0");
var parms = new Dictionary<string, object>();
if (id != null)
{
sb.Append(" and id = @id");
parms.Add("id", id.Value);
}
// THIS LINE IS ADDED.
where?.AddConditions(sb, parms);
return (sb.ToString(), parms);
}
使用示例
如果我们现在对 model/entity
执行 GET
,我们将看到以下 JSON 响应
[
{
"ID":4,
"Name":"Name",
"Deleted":false,
"EntityFields":[
{
"ID":1,
"Name":"FirstName",
"EntityID":4,
"Deleted":false
},
{
"ID":2,
"Name":"LastName",
"EntityID":4,
"Deleted":false
}
]
}
]
类似地,model/entity/4
将仅返回特定实体 ID 的实体和实体字段。
动态透视
我们还需要对实体实例执行 CRUD 操作的能力。虽然 EntityFieldValue
表是实体-实体字段-值记录的集合,但我们不希望用户接触到这个实现细节。相反,CRUD 操作应该按照我们习惯的方式处理 JSON,例如
{
"FirstName": "Marc",
"LastName": "Clifton"
}
这需要在 API 中进行一些关键的(双关语)操作。
首先,让我们看看我们要实现什么。到目前为止,我们只定义了一个实体
并为该实体定义了两个实体字段
所以让我们手动创建实体实例。首先,我们需要创建三个“Name
”实体实例
insert into EntityInstance (EntityID, Deleted) values (4, 0)
insert into EntityInstance (EntityID, Deleted) values (4, 0)
insert into EntityInstance (EntityID, Deleted) values (4, 0)
由于这些是首次创建的记录,我知道生成的主键 ID 将是 1、2 和 3。
接下来,我们将为这些实体实例创建一些实例值
insert into EntityFieldValue (EntityInstanceID, EntityID,
EntityFieldID, value, Deleted) values (1, 4, 1, 'Marc', 0)
insert into EntityFieldValue (EntityInstanceID, EntityID,
EntityFieldID, value, Deleted) values (1, 4, 2, 'Clifton', 0)
insert into EntityFieldValue (EntityInstanceID, EntityID,
EntityFieldID, value, Deleted) values (2, 4, 1, 'Gregory', 0)
insert into EntityFieldValue (EntityInstanceID, EntityID,
EntityFieldID, value, Deleted) values (2, 4, 2, 'House', 0)
insert into EntityFieldValue (EntityInstanceID, EntityID,
EntityFieldID, value, Deleted) values (3, 4, 1, 'Marcus', 0)
insert into EntityFieldValue (EntityInstanceID, EntityID,
EntityFieldID, value, Deleted) values (3, 4, 2, 'Welby', 0)
生成的 EntityFieldValue
记录如下所示
这完全不是我们想要返回给客户端的内容。相反,我们想要像这样
这通过此 SQL 完成
select *
from
(
select ei.ID, ef.Name, [value]
from EntityFieldValue efv
join EntityField ef on ef.ID = efv.EntityFieldID
join EntityInstance ei on ei.ID = efv.EntityInstanceID
) as t
pivot (max([value]) for [Name] in ([FirstName], [LastName])) as p
请注意,Deleted
字段已消失,因为它不在实体“Name
”的实体字段定义中。
问题在于,SQL 硬编码了实体字段名称,而这种架构的全部意义在于实体字段可以动态创建和删除。在 SQLShack 和 Aveek Das 关于 SQL Server 中的动态透视表 的文章的帮助下,我们创建了一个存储过程来执行 SQL,参数化要透视的列和要透视的字段列表。我们无法在上述语句中直接执行此操作——参数化透视语句的唯一方法是通过一个动态执行 SQL 的存储过程,如下所示
CREATE PROCEDURE dbo.EntityPivotTable
@ColumnToPivot NVARCHAR(255),
@ListToPivot NVARCHAR(MAX)
AS
BEGIN
DECLARE @SqlStatement NVARCHAR(MAX)
SET @SqlStatement = N'
select *
from
(
select ei.ID, ef.Name, [value]
from EntityFieldValue efv
join EntityField ef on ef.ID = efv.EntityFieldID and ef.Deleted = 0
join EntityInstance ei on ei.ID = efv.EntityInstanceID and ei.Deleted = 0
where efv.Deleted = 0
) as t
pivot (max([value]) for [' + @ColumnToPivot + '] in ('+@ListToPivot+')) as p';
EXEC(@SqlStatement)
END
然后我们可以通过参数化实体字段名称列表调用 SP,这些名称是根据特定实体在 EntityField
表中的定义而知的。手动操作看起来像这样
EXEC dbo.EntityPivotTable 'Name' ,'[FirstName], [LastName]'
奇怪的聚合 max([value])
是透视语法所必需的,由于在给定实体字段值实例中,每个记录对于实体字段名称只有一个值,因此实际上,max([value]) == value
。
令人惊讶的是,这甚至适用于 null
值
insert into EntityInstance (EntityID, Deleted) values (4, 0)
insert into EntityInstance (EntityID, Deleted) values (4, 0)
insert into EntityFieldValue (EntityInstanceID, EntityID, EntityFieldID, value, Deleted) _
values (4, 4, 1, null, 0)
insert into EntityFieldValue (EntityInstanceID, EntityID, EntityFieldID, value, Deleted) _
values (4, 4, 2, 'Q', 0)
insert into EntityFieldValue (EntityInstanceID, EntityID, EntityFieldID, value, Deleted) _
values (5, 4, 1, null, 0)
insert into EntityFieldValue (EntityInstanceID, EntityID, EntityFieldID, value, Deleted) _
values (5, 4, 2, 'R', 0)
我们看到
所以现在我们知道我们在做什么,至少在查询和如何透视记录方面。归根结底,我实际上会使用 SP_EXECUTESQL
而不是存储过程,因为我还需要传入 where
子句参数和其他未来行为的能力,所以将执行的内容看起来像这样
DECLARE @ColumnToPivot NVARCHAR(255) = 'Name';
DECLARE @ListToPivot NVARCHAR(max) = '[FirstName], [LastName]';
Declare @SqlPivot NVARCHAR(MAX) = N'
select *
from
(
select ei.ID, ef.Name, [value]
from EntityFieldValue efv
join EntityField ef on ef.ID = efv.EntityFieldID and ef.Deleted = 0
join EntityInstance ei on ei.ID = efv.EntityInstanceID and ei.Deleted = 0
where ei.ID = @id
) as t
pivot (max([value]) for [' + @ColumnToPivot + '] in ('+@ListToPivot+')) as p';
exec SP_EXECUTESQL @SqlPivot, N'@id int', @id = 1
这将通过 SQL 构建器部分的模板来尝试。
Entity 控制器
实体控制器非常基本。此时,我希望我能直接在 REST 操作、端点和要调用的代码之间创建映射。可能有一种方法,但 MapRoute
似乎不支持调用方法,更不用说方法的依赖注入了。所以我们有这个
[ApiController]
[Route("[controller]")]
public class EntityController : ControllerBase
{
private IConfiguration cfg;
private TableService ts;
private EntityService es;
public EntityController(IConfiguration configuration, TableService ts, EntityService es)
{
cfg = configuration;
this.ts = ts;
this.es = es;
}
[HttpGet("{entity}")]
public object GetEntities(string entity)
{
// TODO: This needs to support pagination.
var data = es.GetAll(cfg, ts, entity);
return data;
}
[HttpGet("{entity}/{entityid}")]
public object GetEntity(string entity, int entityid)
{
var data = es.GetSingle(cfg, ts, entity, entityid);
return data;
}
[HttpPost("{entity}")]
public object Insert(string entity, Dictionary<string, object> data)
{
var newRecord = es.Insert(cfg, ts, entity, data);
return newRecord;
}
[HttpPatch("{entity}/{entityid}")]
public object Update(string entity, int entityid, Dictionary<string, object> data)
{
var record = es.Update(cfg, ts, entity, entityid, data);
return record;
}
[HttpDelete("{entity}/{entityid}")]
public object Delete(string entity, int entityid)
{
es.Delete(cfg, ts, entity, entityid);
return NoContent();
}
}
Entity 服务
实体服务是发生有趣事情的地方。
基本透视模板
private const string template = @"
DECLARE @ColumnToPivot NVARCHAR(255) = 'Name';
DECLARE @ListToPivot NVARCHAR(max) = '[cols]';
DECKARE @SqlPivot NVARCHAR(MAX) = N'
select *
from
(
select ei.ID, ef.Name, [value]
from EntityFieldValue efv
join EntityField ef on ef.ID = efv.EntityFieldID and ef.Deleted = 0
join EntityInstance ei on ei.ID = efv.EntityInstanceID and ei.Deleted = 0
where efv.Deleted = 0 and [where]
) as t
pivot (max([value]) for [' + @ColumnToPivot + '] in ('+@ListToPivot+')) as p';
exec SP_EXECUTESQL @SqlPivot, [parms]
";
GetAll
请注意,限定符位于实体 ID 上。
public Records GetAll(IConfiguration cfg, TableService ts, string entity)
{
var entityFieldInfo = GetEntityFieldNames(cfg, ts, entity);
var cols = String.Join(",", entityFieldInfo.fieldNames.Select(n => $"[{n}]"));
var sql = template
.Replace("[cols]", cols)
.Replace("[where]", "ei.EntityID = @id")
.Replace("[parms]", "N'@id int', @id = @pid");
var cs = cfg.GetConnectionString("DefaultConnection");
using (var conn = new SqlConnection(cs))
{
var records = conn.Query_
(sql, new { pid = entityFieldInfo.entityId }).Cast<Record>().ToList();
return records;
}
}
GetSingle
请注意,限定符位于实体实例 ID 上。我们不需要限定符中的实体 ID,因为实例 ID 特定于实体,它应该与我们传入的实体名称匹配。
public Record GetSingle(IConfiguration cfg, TableService ts, string entity, int instanceid)
{
var entityFieldInfo = GetEntityFieldNames(cfg, ts, entity);
var cols = String.Join(",", entityFieldInfo.fieldNames.Select(n => $"[{n}]"));
var sql = template
.Replace("[cols]", cols)
.Replace("[where]", "ei.ID = @id")
.Replace("[parms]", "N'@id int', @id = @pid");
var cs = cfg.GetConnectionString("DefaultConnection");
using (var conn = new SqlConnection(cs))
{
var record = conn.Query(sql, new { pid = instanceid }).Cast<Record>().SingleOrDefault();
return record;
}
}
GetEntityFieldNames
正如注释所述,这可以缓存,这样我们就不必不断地访问数据库来获取这些信息。问题就变成了,缓存何时刷新?
private (List<string> fieldNames, int entityId)
GetEntityFieldNames(IConfiguration cfg, TableService ts, string entity)
{
// TODO: This could be cached so we're not hitting the DB for the entity fields.
var entityRec = ts.GetSingle(cfg, "Entity", new Conditions()
{ new Condition("Name", Condition.Op.Equals, entity) });
var entityId = entityRec["ID"].ToInt();
var entityFields = ts.GetAll(cfg, "EntityField", where: new Conditions()
{ new Condition("EntityID", Condition.Op.Equals, entityId) });
var entityFieldNames = entityFields.Select(fields => fields["Name"].ToString()).ToList();
return (entityFieldNames, entityId);
}
请注意这如何利用 TableService
方法。是的,我很快会将硬编码的字符串替换为常量。
使用示例
使用 GET
entity/Name,我们看到以下 JSON 返回
[ {"ID":1, "FirstName":"Marc", "LastName":"Clifton"}, {"ID":2, "FirstName":"Gregory", "LastName":"House"}, {"ID":3, "FirstName":"Marcus", "LastName":"Welby"}, {"ID":4, "FirstName":null, "LastName":"Q"}, {"ID":5, "FirstName":null, "LastName":"R"} ]
这正是我们希望返回给客户端的内容。
使用 GET
entity/Name/2,我们只看到返回了 ID 2
的 Name
记录
{"ID":2,"FirstName":"Gregory","LastName":"House"}
Insert
Insert
其实很简单。我们创建一个 EntityInstance
记录,然后为 JSON 正文传入的每个键值对创建单独的 EntityFieldValue
记录。
public Record Insert(IConfiguration cfg, TableService ts, string entity, Parameters data)
{
var entityFields = GetEntityFields(cfg, ts, entity);
var entityFieldInfo = GetEntityFieldNames(cfg, ts, entity);
var ei = ts.Insert(cfg, "EntityInstance",
new Parameters() { { "EntityId", entityFieldInfo.entityId } });
int entityInstanceId = ei["ID"].ToInt();
bool parmsMatchEntityDefinition = data.All(kvp => entityFields.records.Any
(r => r["Name"].ToString() == kvp.Key));
Assertion.IsTrue(parmsMatchEntityDefinition,
$"One or more fields to insert is not part of the entity {entity} description.
Fields are {String.Join(", ", GetBadFields(data, entityFields.records))}");
// TODO: We need a batch operation.
data.ForEach(kvp =>
{
ts.Insert(cfg, "EntityFieldValue", new Parameters()
{
{ "EntityInstanceID", entityInstanceId },
{ "EntityID", entityFieldInfo.entityId },
{ "EntityFieldID", entityFields.records.Single
(r=>r["Name"].ToString() == kvp.Key)["ID"] },
{ "Value", kvp.Value }
});
});
var ret = GetSingle(cfg, ts, entity, entityInstanceId);
return ret;
}
请注意,我们会验证传入的字段是否在指定实体的 EntityField
描述符中描述。
使用示例
鉴于此 cURL
curl --location --request POST 'https://:8493/entity/Name' \
--header 'Content-Type: application/json' \
--data-raw '{"FirstName": "A", "LastName": "B"}'
我们得到创建的 Name
实体
{"ID": 7, "FirstName": "A", "LastName": "B"}
如果我们在 JSON 正文中指定了一个在 Name
的 EntityField
描述符中不存在的字段,例如这样:{"FirstName": "A", "LastName": "B", "Foo": "C"}
,我们将得到一个异常
System.Exception: One or more fields to insert is not part of the entity Name description.
Fields are Foo
更新
再次强调,update
很直接,只是这里我们需要获取每个要更新的字段值的 EntityFieldValue
ID,并且我们不创建新的 EntityInstance
,而是验证实例是否存在。我们还断言每个实体字段都存在。我们可能可以通过简单地检查 EntityInstance
表来做到这一点,但这在单个实体字段被删除的情况下更健壮。另外请注意注释块,我们有一个实体字段名称与支持表 EntityFieldValue
中的列名称相同的问题。需要修复,特别是 ID
和 Value
列,因为这些可能是合理的实体字段名称。
public Record Update(IConfiguration cfg, TableService ts, string entity,
int entityInstanceId, Parameters data)
{
var entityFields = GetEntityFields(cfg, ts, entity);
var entityFieldInfo = GetEntityFieldNames(cfg, ts, entity);
// We don't want to update the ID, so remove it in case the client passed it in.
// This is problematic -- if the entity has a field called "ID",
// this will remove the legitimate entity-field "ID".
// Which reveals the more general problem that the columns in the table
// that we are updating cannot be used as entity-field names!
// So we have "reserved" entity-field names that cannot be used
// for the entity-field definition, which we should check for when entity-fields are added.
data.Remove("ID");
bool parmsMatchEntityDefinition = data.All(kvp => entityFields.records.Any
(r => r["Name"].ToString() == kvp.Key));
Assertion.IsTrue(parmsMatchEntityDefinition, $"One or more fields to update
is not part of the entity {entity} description.
Fields are {String.Join(", ", GetBadFields(data, entityFields.records))}");
// TODO: We need a batch operation.
data.ForEach(kvp =>
{
int efId = entityFields.records.Single
(r => r["Name"].ToString() == kvp.Key)["ID"].ToInt();
var efv = ts.GetSingle(cfg, "EntityFieldValue", new Conditions()
{
new Condition("EntityInstanceID", Condition.Op.Equals, entityInstanceId),
new Condition("EntityFieldID", Condition.Op.Equals, efId)
});
Assertion.NotNull(efv, $"There is no entity {entity} with ID {entityInstanceId}");
int efvId = efv["ID"].ToInt();
ts.Update(cfg, "EntityFieldValue", efvId, new Parameters() { { "Value", kvp.Value } });
});
var ret = GetSingle(cfg, ts, entity, entityInstanceId);
return ret;
}
使用示例
使用我们从上面的 Insert
API 调用中获得的 ID
,给定此 cURL
curl --location --request PATCH 'https://:8493/entity/Name/7' \
--header 'Content-Type: application/json' \
--data-raw '{"FirstName": "AAA"}'
我们看到返回的 JSON 是更新后的 Name 记录
{"ID": 7, "FirstName": "AAA", "LastName": "B"}
删除
Delete
很有趣,因为它足以软删除 EntityInstance
表中的条目,这让我怀疑 EntityFieldValue
表中的 Deleted 标志是否必要。这个调用如此简单,让我怀疑它是否甚至需要在 EntityService
类中有一个方法,但为了完整性,以及未来的功能(如审计记录),它将发挥作用。
public void Delete(IConfiguration cfg, TableService ts, string entity, int entityInstanceId)
{
ts.Delete(cfg, "EntityInstance", entityInstanceId);
}
使用示例
使用 GET
entity/Name,我们看到,在其他记录中,我们之前创建的 ID 7
的记录
... other records ...
{"ID":5,"FirstName":null,"LastName":"R"},
{"ID":7,"FirstName":"A4","LastName":"C4"}]
当我们删除实体实例时
curl --location --request DELETE 'https://:8493/entity/Name/7'
我们不再看到那条记录
... other records ... {"ID":5,"FirstName":null,"LastName":"R"} ... ID 7 is gone ...
直接查看 EntityInstance
表,我们看到该实例是软删除的
工具
如果我们的架构再花哨,如果没有工具来处理它,也用处不大。每篇文章的大部分内容将涉及工具和元数据设置,前端可以利用这些来做一些实际的事情。出于这些文章的目的,我将依赖 js-grid(是的,它需要 jQuery),作为一个开源网格编辑器。请注意——虽然这是一个功能完善的网格,但它看起来很俗气。然而,它是免费的、轻量级的,并且非常适合演示目的。
页面服务
因为我将解决方案创建为 ASP.NET Core Web API 而不是 ASP.NET Core Web App,目前,我将在各自的控制器中提供内置页面,因为我实在没有耐心通读 ASP.NET Core 中的静态文件,然后再看看它是否有效。StackOverflow 上也有一篇看起来更容易理解的说明 这里。所以目前,我们有
在 EntityController
中
[HttpGet()]
public object GetEntityPage()
{
string contentRootPath = env.ContentRootPath;
string filePath = Path.Combine(contentRootPath, "Website\\entity.html");
return PhysicalFile(filePath, "text/html");
}
并在 SysAdminController
中
[HttpGet()]
public object GetSysAdminPage()
{
string contentRootPath = env.ContentRootPath;
string filePath = Path.Combine(contentRootPath, "Website\\sysadmin.html");
return PhysicalFile(filePath, "text/html");
}
以及一个新的 WebsiteController
[ApiController]
[Route("Website")]
[Route("Scripts")]
public class WebsiteController : ControllerBase
{
private static Dictionary<string, string> mimeMap = new Dictionary<string, string>()
{
{".js", "text/javascript" },
{".ts", "text/javascript" },
{".css", "text/css" },
{".map", "text/map" },
};
private IWebHostEnvironment env;
public WebsiteController(IWebHostEnvironment env)
{
this.env = env;
}
[HttpGet("{fn}")]
public object GetFile(string fn)
{
string contentRootPath = env.ContentRootPath;
string filePath = Path.Combine(contentRootPath, $"Website\\{fn}");
string ext = Path.GetExtension(fn);
string mime = mimeMap[ext];
return PhysicalFile(filePath, mime);
}
[HttpGet("lib/{fn}")]
public object GetLibFile(string fn)
{
string contentRootPath = env.ContentRootPath;
string filePath = Path.Combine(contentRootPath, $"Website\\lib\\{fn}");
string ext = Path.GetExtension(fn);
string mime = mimeMap[ext];
return PhysicalFile(filePath, mime);
}
}
如果您此时感到不适,那没关系。这里的想法是提供一些内置功能来编辑实体模型和实际的实体记录。
前端
前端是一个 TypeScript 应用程序。我将在此处展示的唯一代码是与上面讨论的 API 交互的 AhkmService
类。
export class AhkmService extends Rest {
// Entity Model:
public async getEntities(): Promise<Entity[]> {
return this.get(`${window.location.origin}/sysadmin/entity`);
}
public async getEntityFields(): Promise<EntityField[]> {
return this.get(`${window.location.origin}/sysadmin/entityfield`);
}
public async addEntity<T>(entity: string, data: object): Promise<T> {
return this.post(`${window.location.origin}/sysadmin/${entity}`, data);
}
public async updateEntity<T>(entity: string, id: number, data: object): Promise<T> {
return this.patch(`${window.location.origin}/sysadmin/${entity}/${id}`, data);
}
public async deleteEntity(entity: string, id: number): Promise<void> {
return this.delete(`${window.location.origin}/sysadmin/${entity}/${id}`);
}
// Records:
public async getRecords(entity: string): Promise<EntityRecord[]> {
return this.get(`${window.location.origin}/entity/${entity}`);
}
public async addRecord(entity: string, data: object): Promise<EntityRecord> {
return this.post(`${window.location.origin}/entity/${entity}`, data);
}
public async updateRecord(entity: string, id: number, data: object): Promise<EntityRecord> {
return this.patch(`${window.location.origin}/entity/${entity}/${id}`, data);
}
public async deleteRecord(entity: string, id: number): Promise<void> {
return this.delete(`${window.location.origin}/entity/${entity}/${id}`);
}
}
编辑实体模型
路径:/sysadmin
在此截图中
我们可以看到 Name
实体及其两个字段 FirstName
和 LastName
,以及我创建的另一个实体 Contact
。
编辑记录
路径:/entity
请注意我们有两个网格——一个用于实体,右边是与该实体关联的记录。
如果我们点击名为“Name
”的实体,我们会看到我们之前创建的记录
我们可以创建、编辑、更新和删除这些记录,就像它们由实际表支持一样。
同样,对于 Contact
实体,记录网格显示了我上面定义的字段和任何数据
这就是说——我们拥有了自适应分层知识模型核心概念的功能原型,特别是“自适应”部分。
结论
这开始将我2011年(十年前!)关于 面向关系编程 的工作,作为Web API,付诸实现。
完成了什么?
- 这里介绍的 API 允许我们创建对象模型,而无需数据库中每个“实体”的物理表。
- 后端消除了 C# 类模型。
- 用户界面可根据模型定义表中指定的字段进行自适应。
- 这通过四个小型元模型表完成。
- API 允许我们对元模型表执行 CRUD 操作。
- API 允许我们以客户端期望的方式对模型实例执行 CRUD 操作,即处理记录集合。
这种方法的一些强大成果
- 因为每个实体的所有字段值都存储在一个表中,所以在所有实体中搜索特定值变得微不足道。
- 比如说,在一个记录管理应用程序中,我想查找警察、消防、法院和财产实体中的所有记录。
- 假设针对这些实体中的每一个,人名字段的定义是一致的,我们现在可以返回出现该人名的实体。
- 这通常也是 NoSql 数据库的特点。
- 定制实体和实体字段变得微不足道,无需触及 schema、后端或前端。
- 不再需要迁移。
- 不再需要代码发布。
- 如果前端设计为适应实体中的字段,也不需要更新。
- 不再有数百个字段的表和模型,其中大部分未被使用,但为了满足特定客户需求而存在。
业务逻辑当然受益于实体模型,但在需要业务逻辑的情况下,未来的文章将演示业务逻辑以及映射到所需模型的类如何作为插件服务实现,不影响核心应用程序代码,以及如何在表示记录的 Dictionary<string, object>
上使用扩展方法。
第二部分将涵盖的内容
第二部分将实现项目名称“自适应分层知识管理元模型”中的分层方面。
新功能
- 接下来要添加的是
ParentChildMap
实例和AllowableParentChildRelationship
模型,这将使我们能够通过创建实体关系的层次结构来做更多有趣的事情。我将演示如何使用以下实体创建食谱书- 食谱创建者(一个
Name
实体) - 食谱配料(一个新的
RecipeIngredient
实体) - 食谱说明(一个新的
RecipeStep
实体)
- 食谱创建者(一个
- 我们将看到如何以不同的方式处理这些关系
- 我们有哪些食谱?
- 我们有哪些来自特定人的食谱?
- 我们有哪些使用特定成分的食谱?
- 对实体字段进行排序,以便我们可以重新排列记录网格列的默认顺序。
重构
- 硬编码的实际表名和表列名。
- 审查命名约定,因为我使用“实体”一词来指代用户在模型中定义的模型和集合名称。
- 正确提供内置网页,而不是我目前实现的蹩脚方法。
约束
EntityField
应在EntityID
、Deleted
上是唯一的。- 实体应在
Name
、Deleted
上是唯一的。 EntityInstance
应在InstanceID
、Deleted
上是唯一的。EntityFieldValue
应在InstanceID
、EntityID
、EntityFieldID
、Deleted
上是唯一的。
索引
- 需要实现索引以提高性能。
第二部分就这些了!
历史
- 2021年4月1日:初始版本