名称:动态架构设计
描述:在使用EF Core JSON列实现灵活内容模式时使用,使用 OwnsOne().ToJson() 模式,或设计避免迁移的动态字段存储。涵盖JSON列配置、JSON属性的LINQ查询、索引策略,以及无头CMS架构的模式演化模式。
允许工具:阅读、全局搜索、查找、任务、技能
使用EF Core JSON列进行动态架构设计
使用EF Core JSON列实现灵活内容模式的指南,允许在不进行数据库迁移的情况下添加动态自定义字段。
何时使用此技能
- 为CMS内容类型设计自定义字段存储
- 实现每个内容实例变化的动态属性
- 避免因模式更改而频繁进行数据库迁移
- 在EF Core中使用LINQ查询JSON数据
- 规划JSON列的索引策略
- 从EAV(实体-属性-值)迁移到JSON存储
EF Core JSON列基础
基本配置(.NET 10 / EF Core 10)
// 包含JSON存储自定义字段的实体
public class ContentItem
{
public Guid Id { get; set; }
public string ContentType { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public DateTime CreatedUtc { get; set; }
// 用于动态字段的JSON列
public CustomFieldsData CustomFields { get; set; } = new();
}
// 作为JSON存储的拥有实体
public class CustomFieldsData
{
public Dictionary<string, object?> Fields { get; set; } = new();
public Dictionary<string, FieldMetadata> Metadata { get; set; } = new();
}
public class FieldMetadata
{
public string FieldType { get; set; } = string.Empty;
public bool IsRequired { get; set; }
public string? DisplayName { get; set; }
}
DbContext配置
public class ContentDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ContentItem>(entity =>
{
entity.HasKey(e => e.Id);
// 使用ToJson()配置JSON列
entity.OwnsOne(e => e.CustomFields, builder =>
{
builder.ToJson();
});
});
}
}
JSON列模式
模式1:类型化自定义字段
最适合在编译时已知字段模式的情况。
// 强类型自定义字段
public class ArticleFields
{
public string? Subtitle { get; set; }
public List<string> Tags { get; set; } = new();
public AuthorInfo? Author { get; set; }
public int? ReadTimeMinutes { get; set; }
public bool IsFeatured { get; set; }
}
public class AuthorInfo
{
public Guid AuthorId { get; set; }
public string DisplayName { get; set; } = string.Empty;
public string? Bio { get; set; }
}
// 使用类型化字段的实体
public class Article
{
public Guid Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public ArticleFields Fields { get; set; } = new();
}
// 配置
modelBuilder.Entity<Article>(entity =>
{
entity.OwnsOne(e => e.Fields, builder =>
{
builder.ToJson();
builder.OwnsOne(f => f.Author);
});
});
模式2:动态属性包
最适合完全动态模式,其中字段随实例变化。
public class DynamicContent
{
public Guid Id { get; set; }
public string ContentType { get; set; } = string.Empty;
// 灵活属性包
public JsonDocument? Properties { get; set; }
}
// 使用字典的替代方法
public class FlexibleContent
{
public Guid Id { get; set; }
public string ContentType { get; set; } = string.Empty;
public Dictionary<string, JsonElement> Fields { get; set; } = new();
}
模式3:混合方法(推荐)
将常见字段的固定列与扩展的JSON列结合。
public class ContentItem
{
// 固定列(索引化,频繁查询)
public Guid Id { get; set; }
public string ContentType { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string? Slug { get; set; }
public ContentStatus Status { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime? PublishedUtc { get; set; }
// 用于类型特定和自定义字段的JSON列
public ContentExtensions Extensions { get; set; } = new();
}
public class ContentExtensions
{
// 作为嵌套JSON存储的部分特定数据
public TitlePartData? TitlePart { get; set; }
public SeoPartData? SeoPart { get; set; }
public MediaPartData? MediaPart { get; set; }
// 完全动态的自定义字段
public Dictionary<string, object?> CustomFields { get; set; } = new();
}
查询JSON列
在JSON属性上的LINQ查询
// 查询嵌套JSON属性
var featuredArticles = await context.Articles
.Where(a => a.Fields.IsFeatured == true)
.ToListAsync();
// 查询嵌套对象属性
var articlesByAuthor = await context.Articles
.Where(a => a.Fields.Author!.AuthorId == authorId)
.ToListAsync();
// 查询数组包含
var taggedArticles = await context.Articles
.Where(a => a.Fields.Tags.Contains("technology"))
.ToListAsync();
// 按JSON属性排序
var orderedArticles = await context.Articles
.OrderByDescending(a => a.Fields.ReadTimeMinutes)
.ToListAsync();
复杂JSON查询的原始SQL
// SQL Server JSON_VALUE
var results = await context.ContentItems
.FromSqlRaw(@"
SELECT * FROM ContentItems
WHERE JSON_VALUE(Extensions, '$.CustomFields.rating') > 4
")
.ToListAsync();
// PostgreSQL jsonb运算符
var results = await context.ContentItems
.FromSqlRaw(@"
SELECT * FROM ""ContentItems""
WHERE ""Extensions""->>'CustomFields'->>'category' = 'tech'
")
.ToListAsync();
索引策略
为频繁查询的JSON属性使用计算列
-- SQL Server:添加计算列
ALTER TABLE ContentItems
ADD Status AS JSON_VALUE(Extensions, '$.status') PERSISTED;
-- 在计算列上创建索引
CREATE INDEX IX_ContentItems_Status ON ContentItems(Status);
PostgreSQL的GIN索引用于JSONB
-- 索引整个JSON列
CREATE INDEX IX_ContentItems_Extensions ON "ContentItems"
USING GIN ("Extensions");
-- 索引特定路径
CREATE INDEX IX_ContentItems_Tags ON "ContentItems"
USING GIN (("Extensions"->'CustomFields'->'tags'));
EF Core迁移用于计算列
migrationBuilder.Sql(@"
ALTER TABLE ContentItems
ADD ComputedStatus AS JSON_VALUE(Extensions, '$.SeoPart.noIndex') PERSISTED;
CREATE INDEX IX_ContentItems_ComputedStatus
ON ContentItems(ComputedStatus);
");
模式演化
添加新字段
无需迁移 - 只需更新类并序列化:
// 之前
public class ArticleFields
{
public string? Subtitle { get; set; }
}
// 之后 - 无需迁移
public class ArticleFields
{
public string? Subtitle { get; set; }
public string? Summary { get; set; } // 新字段
public List<string> RelatedLinks { get; set; } = new(); // 新字段
}
处理缺失/空属性
// 使用带默认值的可空类型
public class ContentFields
{
public string? OptionalField { get; set; }
public int RequiredWithDefault { get; set; } = 0;
public List<string> CollectionWithDefault { get; set; } = new();
}
// 带空处理的查询
var items = await context.ContentItems
.Where(c => c.Extensions.CustomFields != null
&& c.Extensions.CustomFields.ContainsKey("rating"))
.ToListAsync();
模式更改的数据迁移
// 后台作业以迁移现有数据
public async Task MigrateContentSchema(ContentDbContext context)
{
var batchSize = 100;
var skip = 0;
while (true)
{
var items = await context.ContentItems
.OrderBy(c => c.Id)
.Skip(skip)
.Take(batchSize)
.ToListAsync();
if (!items.Any()) break;
foreach (var item in items)
{
// 将旧模式转换为新模式
if (item.Extensions.CustomFields.TryGetValue("old_field", out var value))
{
item.Extensions.CustomFields["new_field"] = value;
item.Extensions.CustomFields.Remove("old_field");
}
}
await context.SaveChangesAsync();
skip += batchSize;
}
}
性能考虑
何时使用JSON列
| 场景 | 使用JSON列 | 使用常规列 |
|---|---|---|
| 频繁过滤/排序 | 否 | 是 |
| 很少查询,仅用于显示 | 是 | 否 |
| 随内容类型变化 | 是 | 否 |
| 在所有实例中固定 | 否 | 是 |
| 作为唯一约束的一部分 | 否 | 是 |
| 需要全文搜索 | 考虑 | 是 |
最佳实践
做:
- 使用混合方法(固定 + JSON)
- 索引频繁查询的JSON路径
- 当模式已知时使用类型化DTO
- 在应用层验证JSON结构
- 对大JSON数组使用分页
不做:
- 在JSON中存储大型二进制数据
- 用于外键关系
- 仅依赖JSON查询性能关键路径
- 存储深层嵌套层次(尽可能扁平化)
- 跳过用户提供数据的模式验证
序列化配置
System.Text.Json选项
services.AddDbContext<ContentDbContext>(options =>
{
options.UseSqlServer(connectionString, sqlOptions =>
{
// 配置JSON序列化
});
});
// 自定义JsonSerializerOptions
public static class JsonDefaults
{
public static JsonSerializerOptions ContentOptions { get; } = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters =
{
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)
}
};
}
自定义类型转换器
// 用于不能直接序列化的复杂类型
public class PolymorphicFieldConverter : JsonConverter<object>
{
public override object? Read(ref Utf8JsonReader reader,
Type typeToConvert, JsonSerializerOptions options)
{
using var doc = JsonDocument.ParseValue(ref reader);
var root = doc.RootElement;
// 从鉴别器确定类型
if (root.TryGetProperty("$type", out var typeElement))
{
var typeName = typeElement.GetString();
// 解析并反序列化适当类型
}
return root.Clone();
}
public override void Write(Utf8JsonWriter writer,
object value, JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, value, value.GetType(), options);
}
}
内容部分JSON存储
在单个JSON列中存储多个部分
public class ContentItem
{
public Guid Id { get; set; }
public string ContentType { get; set; } = string.Empty;
// 所有部分存储在单个JSON列中
public ContentParts Parts { get; set; } = new();
}
public class ContentParts
{
public TitlePart? Title { get; set; }
public BodyPart? Body { get; set; }
public AutoroutePart? Autoroute { get; set; }
public PublishLaterPart? PublishLater { get; set; }
public SeoMetaPart? SeoMeta { get; set; }
// 自定义部分的扩展点
public Dictionary<string, JsonElement> CustomParts { get; set; } = new();
}
// 部分定义
public record TitlePart(string Title, string? DisplayTitle = null);
public record BodyPart(string Html, string? PlainText = null);
public record AutoroutePart(string Path, bool IsCustom = false);
public record PublishLaterPart(DateTime? ScheduledUtc, string? TimeZone = null);
public record SeoMetaPart(string? MetaTitle, string? MetaDescription, bool NoIndex = false);
验证模式
对JSON字段使用流畅验证
public class ContentItemValidator : AbstractValidator<ContentItem>
{
public ContentItemValidator(IContentTypeRegistry typeRegistry)
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(200);
RuleFor(x => x.Extensions)
.SetValidator(new ContentExtensionsValidator(typeRegistry));
}
}
public class ContentExtensionsValidator : AbstractValidator<ContentExtensions>
{
public ContentExtensionsValidator(IContentTypeRegistry typeRegistry)
{
When(x => x.SeoPart != null, () =>
{
RuleFor(x => x.SeoPart!.MetaTitle)
.MaximumLength(60)
.When(x => x.SeoPart?.MetaTitle != null);
RuleFor(x => x.SeoPart!.MetaDescription)
.MaximumLength(160)
.When(x => x.SeoPart?.MetaDescription != null);
});
}
}
相关技能
content-type-modeling- 内容类型层次和组合content-versioning- 带有JSON快照的版本历史headless-api-design- 基于JSON内容的API契约