65.9K
CodeProject 正在变化。 阅读更多。
Home

JsonPathToModel:通用数据导入器

starIconstarIconstarIconstarIconstarIcon

5.00/5 (3投票s)

2024年8月28日

CPOL

6分钟阅读

viewsIcon

5480

通过 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 文件夹,它有三个类 CustomerProductPurchase。这些类直接映射到 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 中找到所有配置数据

该配置为每个文件提供了一个映射列表。每个映射都有 JsonPathPosition

让我们看看 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 方法找到文件对应的映射配置。然后它创建一个模型来处理文件行。然后对于每个配置映射,它调用 JsonPathModelNavigatorSetValue 方法,使用 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 方法接受快照并在一个事务中插入 CustomersProductsPurchases

    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 文件中位置与目标模型属性之间的映射,这将足以将数据导入您的系统。

感谢阅读。

历史

在此处保持您所做的任何更改或改进的实时更新。

© . All rights reserved.