JsonPathToModel:通用数据导入器
通过 JSONPath 映射实现一个与输入文件格式无关的可配置数据导入器非常简单
引言
为了演示如何在数据导入中使用 JSONPath,我创建了一个 Visual Studio 解决方案,其中包含洋葱架构项目的标准结构
- App (控制台)
- 定义域
- Application
- Infrastructure (MS SQL 数据库上下文)
- 单元测试
您可以从 GitHub https://github.com/euklad/BlogCode 文件夹 Story-11-Generic-Data-Import
下载
本文解释了所使用的代码和方法。
背景
JsonPathToModel 是一个 C# .NET 开源库,它允许使用 JSONPath 点表示法在内存模型中导航,提取和设置属性值。
Github:https://github.com/Binoculars-X/JsonPathToModel
NuGet:https://nuget.net.cn/packages/JsonPathToModel
解决方案概览
让我们首先在本地克隆代码,并在 Visual Studio 2022 中打开解决方案文件 .\Story-11-Generic-Data-Import\Story-11-Generic-Data-Import.sln
。
DataImport.Domain
项目包含 DBContext 实体和模型类,它们内部没有任何逻辑,只用于存储数据和表示数据库表。
DataImport.Application
项目负责业务逻辑,映射算法和功能辅助器也应该在那里实现。
DataImport.Infrastructure
项目包含 DbContext 和数据持久化逻辑,它还管理数据库迁移。
DataImport.Console
项目是一个控制台应用程序,它组装所有组件并分几个简单的步骤执行数据导入过程
- 接受命令行参数
- 执行数据导入应用程序算法
- 执行持久化层以保存导入的数据
定义域
让我们看一下 Entities
文件夹,它有三个类 Customer
、Product
和 Purchase
。这些类直接映射到 SQL 数据库表,并用于模型类中存储数据。如您所见,实体有一些额外的属性,这些属性仅用于实现实体之间的关系。这些关系对于 Entity Framework 的正确工作很重要。
例如 Purchase.cs
namespace DataImport.Domain.Entities;
public class Purchase
{
public int Id { get; set; }
public string? ExternalId { get; set; }
public string? CustomerExternalId { get; set; }
public string? ProductExternalId { get; set; }
public decimal? TotalAmount { get; set; }
public DateTime? PeriodStartDate { get; set; }
public DateTime? PeriodEndDate { get; set; }
public DateTime? OrderDate { get; set; }
public DateTime? PaymentDate { get; set; }
public string? Source { get; set; }
public DateTime Created { get; set; } = DateTime.UtcNow;
public int CustomerId { get; set; }
public Customer? Customer { get; set; }
public int ProductId { get; set; }
public Product? Product { get; set; }
}
Model
文件夹包含 DataSnapshotModel
- 一个表示导入记录完整快照的类,它有三个集合,分别对应每个实体。
另一个模型类 DataLineModel
用于逐条记录处理逻辑,一次存储一条 CSV 文件记录。
using DataImport.Domain.Entities;
namespace DataImport.Domain.Model;
public class DataSnapshotModel
{
public List<Customer> Customers { get; set; } = [];
public List<Product> Products { get; set; } = [];
public List<Purchase> Purchases { get; set; } = [];
}
public class DataLineModel
{
public Customer Customer { get; set; } = new();
public Product Product { get; set; } = new();
public Purchase Purchase { get; set; } = new();
}
最后一个模型类 MappingConfig
用于反序列化 JSON 配置并将其作为 C# 代码可访问的内存配置对象提供。
namespace DataImport.Domain.Model;
public class MappingConfig
{
public Dictionary<string, List<FileMapping>> FileMappings { get; set; } = [];
}
public class FileMapping
{
public string JsonPath { get; set; } = null!;
public int Position { get; set; }
}
如您所见,Domain
项目中没有逻辑,只有带有属性的类用于存储数据。
Application
Application
项目封装了所有业务逻辑和算法,完成了除持久化之外的所有工作。
FileHelper
是一个静态工具类,封装了文件处理逻辑。它具有将 CSV 文件加载到内存列表集合以及从 JSON 文件反序列化 MappingConfig 的方法。
using DataImport.Domain.Model;
using System.Text.Json;
namespace DataImport.Application.Helpers;
public static class FileHelper
{
public static List<string> GetCsvFileLines(string fileName)
{
var result = new List<string>();
using Stream stream = File.OpenRead(fileName)!;
using StreamReader reader = new StreamReader(stream);
while (!reader.EndOfStream)
{
result.Add(reader.ReadLine()!);
}
return result;
}
public static MappingConfig LoadConfig(string fileName)
{
using Stream stream = File.OpenRead(fileName)!;
using StreamReader reader = new StreamReader(stream);
var json = reader.ReadToEnd();
var config = JsonSerializer.Deserialize<MappingConfig>(json)!;
return config;
}
}
最有趣的类是 DataImporter
,它实现了所有映射逻辑,并将任何格式和结构的 CSV 文件转换为相应的模型类。为了实现这种灵活性,MappingConfig 应该提供从 CSV 文件位置到模型中相应属性的映射,使用 JSONPath 点表示法。
JSON 配置如下所示
{
"FileMappings": {
"PersonExport.csv": [
{
"JsonPath": "$.Customer.ExternalId",
"Position": 0
},
{
"JsonPath": "$.Customer.FirstName",
"Position": 1
},
...
您可以在 .\DataImport.Tests\TestData\TestMappingConfig.json
中找到所有配置数据
该配置为每个文件提供了一个映射列表。每个映射都有 JsonPath
和 Position
。
让我们看看 DataImporter
是如何工作的
using DataImport.Domain.Model;
using JsonPathToModel;
using DataImport.Application.Helpers;
namespace DataImport.Application.Mappings;
public class DataImporter
{
private readonly IJsonPathModelNavigator _navigator;
public DataImporter()
{
_navigator = new JsonPathModelNavigator(
new NavigatorConfigOptions
{
OptimizeWithCodeEmitter = false
});
}
public DataSnapshotModel ReadModelFromFiles(List<string> fileList, MappingConfig config)
{
var model = new DataSnapshotModel();
foreach (var filePath in fileList)
{
var csv = FileHelper.GetCsvFileLines(filePath);
// skip the 1st line with column definitions
foreach (var line in csv.Skip(1))
{
var cells = line.Split(',');
ImportFileLine(model, config, filePath, cells);
}
}
return model;
}
private void ImportFileLine(DataSnapshotModel model, MappingConfig config, string filePath, string[] cells)
{
var fileName = Path.GetFileName(filePath);
var mappings = config.FileMappings[fileName];
var target = new DataLineModel();
// iterate through mappings and populate record values from cells
foreach (var mapping in mappings)
{
_navigator.SetValue(target, mapping.JsonPath, cells[mapping.Position]);
}
// add to model only those entities that have at least ExternalId updated
if (target.Customer.ExternalId != null)
{
target.Customer.Source = fileName;
model.Customers.Add(target.Customer);
}
if (target.Product.ExternalId != null)
{
target.Product.Source = fileName;
model.Products.Add(target.Product);
}
if (target.Purchase.ExternalId != null)
{
target.Purchase.Source = fileName;
model.Purchases.Add(target.Purchase);
}
}
}
在构造函数中,我们创建了一个 JsonPathModelNavigator
实例,它将用于使用 JSONPath 绑定(例如 "$.Customer.ExternalId"
)设置模型属性值。
ReadModelFromFiles
接受一个 CSV 文件列表进行处理以及 MappingConfig
。它遍历文件,并对于每个文件使用辅助方法 GetCsvFileLines
读取文件内容。然后对于文件的每一行,它将该行拆分为单元格并执行 ImportFileLine
方法。
ImportFileLine
方法找到文件对应的映射配置。然后它创建一个模型来处理文件行。然后对于每个配置映射,它调用 JsonPathModelNavigator
的 SetValue
方法,使用 JsonPath 从 CSV 行单元格更新模型。
例如,对于 PersonExport.csv
文件的第一个映射,模型更新将如下所示
_navigator.SetValue(target, "$.Customer.ExternalId", cells[0]);
它等同于 .NET 中的直接赋值操作
target.Customer.ExternalId = cells[0];
JsonPathModelNavigator
将在目标模型中找到适当的属性,将值转换为目标数据类型,并进行赋值。
然后,一旦填充了模型记录,我们需要检查哪些模型对象受到影响,将 ExternalId
与 null 进行比较。受影响的对象被添加到最终快照中,最终快照将包含所有映射到模型对象的 CSV 记录。
基础结构
该演示使用 Entity Framework 将数据存储在 SQL Server 数据库中。MyDbContext
类包含所有必需的数据库定义,并且非常标准。它会自动应用数据库迁移。它只会在第一次运行时创建和更新数据库模式。它使用命名连接字符串,该字符串已添加到控制台 appsettings.json
文件中。
数据库模式由这张图表示
项目已经有迁移来生成数据库,但如果您想进行一些更改并添加更多迁移,解决方案的 readme.md
文件中有说明。
DataAccessService
实现持久化逻辑并将模型快照转换为数据库插入操作。ImportDataSnapshot
方法接受快照并在一个事务中插入 Customers
、Products
和 Purchases
public void ImportDataSnapshot(DataSnapshotModel snapshot)
{
using (var transaction = _db.Database.BeginTransaction())
{
InsertCustomers(snapshot);
InsertProducts(snapshot);
InsertPurchases(snapshot);
transaction.Commit();
}
}
在插入之前,InsertPurchases
方法通过 ExternalId 解析 Foreign Key
属性到 Customer 和 Product
private void InsertPurchases(DataSnapshotModel snapshot)
{
// resolve Id by ExternalId
var customerDict = snapshot.Customers.ToDictionary(c => c.ExternalId!, c => c.Id);
var productDict = snapshot.Products.ToDictionary(c => c.ExternalId!, c => c.Id);
foreach (var item in snapshot.Purchases)
{
item.CustomerId = customerDict[item.CustomerExternalId!];
item.ProductId = productDict[item.ProductExternalId!];
_db.Add(item);
}
_db.SaveChanges();
}
控制台
控制台应用程序是一个可在本地测试的可运行项目。它有一个 .\Properties\launchSettings.json
文件,其中指定了运行所需的详细信息
{ "profiles": { "DataImport.Console": { "commandName": "Project", "commandLineArgs": "TestMappingConfig.json PersonExport.csv SubscriptionExport.csv OrderExport.csv" } } }
属性 "commandLineArgs"
包含用于调试的测试 JSON 和 CSV 数据文件名。
您可以看到提到的文件作为链接。实际文件保存在 .\Tests\TestData\
文件夹中。
Program
类通过定义 Host builder
开始——这是一个标准的样板代码,用于为 Dependency Injection
(DI
) 设置 IServiceProvider。它还添加了对包含连接字符串的 appsettings.json
文件的引用
{ "ConnectionStrings": { "Default": "Data Source=.;Initial Catalog=mydatabase;Integrated Security=True;TrustServerCertificate=True;" } }
DI 用于构造 DataAccessService。然后我们读取 MappingConfig 配置并将 CSV 文件名组合成一个列表,将所有这些提供给 DataImporter::ReadModelFromFiles。它返回我们使用 ImportDataSnapshot 方法保存到数据库的快照
using DataImport.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using DataImport.Application.Mappings;
using DataImport.Application.Helpers;
namespace DataImport.Console;
class Program
{
static void Main(string[] args)
{
// app builder
var app = Host.CreateDefaultBuilder()
.ConfigureAppConfiguration((hostingContext, config) =>
{
config.Sources.Clear();
config.AddConfiguration(hostingContext.Configuration);
config.AddJsonFile("appsettings.json");
config.AddJsonFile($"appsettings.Development.json", true, true);
})
.ConfigureServices(services =>
{
services.AddDbContext<MyDbContext>(options => options.UseSqlServer(), contextLifetime: ServiceLifetime.Singleton);
services.AddSingleton<IDataAccessService, DataAccessService>();
})
.Build();
var dataService = app.Services.GetService<IDataAccessService>()!;
// read arguments
var configFile = args[0];
var config = FileHelper.LoadConfig(configFile);
var csvFiles = new List<string>();
for (int i = 1; i < args.Length; i++)
{
csvFiles.Add(args[i]);
}
// read snapshot
var dataImporter = new DataImporter();
var shapshot = dataImporter.ReadModelFromFiles(csvFiles, config);
// save snapshot to db
dataService.ImportDataSnapshot(shapshot);
System.Console.ReadKey();
}
}
如果您运行该应用程序,它应该会为您创建数据库,并用测试文件中的数据填充所有三个表。
如果您从表中选择数据,您应该会看到类似这样的内容
单元测试
DataImport.Tests
项目有一个 TestData
文件夹,其中包含测试数据文件和测试映射配置。Console
应用程序重新使用这些文件进行运行。
但是,您不必运行 Console
应用程序,可以尝试运行 UnitTest1
类中的单元测试。
例如,这个测试调用 ReadModelFromFiles
并检查 CSV 文件中的所有三行是否正确导入到快照中
[Fact]
public void DataImporter_Should_ReadCustomer()
{
var config = LoadConfig("DataImport.Tests.TestData.TestMappingConfig.json");
List<string> fileList = ["./TestData/PersonExport.csv"];
var importer = new DataImporter();
var model = importer.ReadModelFromFiles(fileList, config);
Assert.NotNull(model);
Assert.Equal(3, model.Customers.Count);
Assert.Equal("C00006", model.Customers[0].ExternalId);
Assert.Empty(model.Products);
Assert.Empty(model.Purchases);
}
我相信,如果您的代码很容易被单元测试覆盖,则表明设计良好。
摘要
本文旨在演示当您无法预测所有未来可能的使用情况时,使用 JSONPath 访问内存模型的灵活性。
我们讨论了一种通用的模型映射方法,您可以在其中配置一组 CSV 文件中位置与目标模型属性之间的映射,这将足以将数据导入您的系统。
感谢阅读。
历史
在此处保持您所做的任何更改或改进的实时更新。