维拓标准接口开发项目总结

引言

维拓标准接口的开发也基本完成了,这也是我首次用 C# 完成定制项目。之前 Unity 学的很多东西都忘掉了,再捡起来用还是有些吃力,所以写了这篇博客总结一下。

C# 编程范式

数组越界

老生常谈的问题了,这里构造 Paper 的时候 item[] 如果没有这些索引又会抛出异常,但自己写的时候经常注意不到:

+if (item.Length < 2)
+ continue;

+try
+{
Paper paper = new Paper(item[0], Double.Parse(item[1]), Double.Parse(item[2]));
result.Add(paper);
+}

文件路径合并

之前对于需要合并的路径我都是这么通过对 string 直接进行加减来操作的:

string FilePath1 = @"C:\Users\MFGYF-WXY\source\repos";
string FilePath2 = @"\PLMInterface\bin\cadext.exe"
string MergeFilePath = FilePath1 + FilePath2;

但这种方式很业余,这种情况正确的处理是用 Path.Combine() 方法将两个路径进行合并:

string FilePath1 = @"C:\Users\MFGYF-WXY\source\repos";
string FilePath2 = @"\PLMInterface\bin\cadext.exe"
string MergeFilePath = Path.Combine(FilePath1, FilePath2);

程序配置与 <bindingRedirect/>

在完成部分接口把程序打包成exe时重新校对了一下 NewtonJson 的版本,之前在 Nuget 上下载的是 13 版本,但是 GStarCAD 目录下的版本是 12。将版本调整之后 exe 无法正常使用:

未经处理的异常:SystemI0.Fi1eLoadException:未能加载文件或程序集“Newtonsoft.Json,Version=13.0.0.0 Culture=neutral Pub1icKeyToken=30ad4fe6b2a6aeed”或它的某一个依赖项。找到的程序集清单定义与程序集引用不匹配。(异常来自 HRESULT:0x80131040)--->SystemIO.Fi1eLoadException:未能加载文件或程序集“Newtonsoft.Ison,Version=12.0.0.0,Culture=neutral, PublicKe yToken=30ad4fe6b2a6aeed”或它的某一个依赖项。找到的程序集清单定义与程序集引用不匹配。(异常来自 HRESULT:0x80131040)

再次检查 Nuget 版本已经调整过来了,但是编译之后的 exe 还是会报错 NewtonJson 版本错误。其实只需要调整一下 app.config 文件:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
</startup>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-13.0.0.0" newVersion="13.0.0.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>

问题在于 <bindingRedirect/> 这个标签,微软官方API文档 这里有比较详细的解释。

将一个程序集版本重定向到另一个版本。

<bindingRedirect
oldVersion="existing assembly version"
newVersion="new assembly version"/>
  • oldVersion:指定最初请求的程序集的版本。 程序集版本号的格式为 major.minor.build.revision。 该版本号的每个部分的有效值介于 0 和 65535 之间。
  • newVersion:指定要用来取代最初请求的版本的程序集版本(格式为:n.n.n.n) ,此值可以指定 oldVersion 之前的版本。

官方还给出了一个示例演示如何将一个程序集版本重定向到另一个版本:

<configuration>  
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="myAssembly"
publicKeyToken="32ab4ba45e0a69a1"
culture="neutral" />
<bindingRedirect oldVersion="1.0.0.0"
newVersion="2.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>

所以把这个标签删掉,程序就可以正常运行了,这是一个知识盲点记录下来。

读取 .dat 文件数据

所需要读取的 PaperSet.dat 长相如下,包含了国标图幅的各个数据尺寸:

Name   B      L     a     c    e     k    BD     LD
A0 841 1189 25 10 20 1 16 12
A1 594 841 25 10 20 1 12 8
A2 420 594 25 10 10 1 8 6
A3 297 420 25 5 10 1 2 2
A4 210 297 25 5 10 1 2 2
A0X2 1189 1682 25 10 20 3 16 24
A0X3 1189 2523 25 10 20 3 16 36
A1X3 841 1783 25 10 20 3 12 24
A1X4 841 2378 25 10 20 3 12 32
A2X3 594 1261 25 10 20 3 8 18
A2X4 594 1682 25 10 20 3 8 24
A2X5 594 2102 25 10 20 3 8 24
A3X3 420 891 25 10 10 2 6 12
A3X4 420 1189 25 10 10 2 6 16
A3X5 420 1486 25 10 10 2 6 10
A3X6 420 1783 25 10 10 3 6 12
A3X7 420 2080 25 10 10 3 6 14
A4X3 297 630 25 5 10 2 2 8
A4X4 297 841 25 5 10 2 2 12
A4X5 297 1051 25 5 10 2 2 12
A4X6 297 1261 25 5 10 3 2 12
A4X7 297 1471 25 5 10 3 2 14
A4X8 297 1682 25 5 10 3 2 16
A4X9 297 1892 25 5 10 3 2 18

观察数据结构,每行数据均以若干空格分隔图幅名称、长度、宽度等等数据,所以我们需要逐行去读取,先简单声明一个图幅信息类包含图幅名称和长宽:

/// <summary>
/// 国标图幅信息类
/// </summary>
public class Paper
{
public Paper(string Name, double Width, double Length)
{
this.Name = Name;
this.Length = Length;
this.Width = Width;
}

/// <summary>
/// 图幅名称
/// eg. A4
/// </summary>
public string Name { get; }

/// <summary>
/// 图幅长度
/// </summary>
public double Length { get; }

/// <summary>
/// 图幅宽度
/// </summary>
public double Width { get; }
}

之后通过 StreamReader 逐行读取(首行不读取)即可:

List<Paper> result = new List<Paper>();
string path = Path.Combine(installPath, @"MCADSetting\PaperSet.dat");

// 逐行读取 PaperSet.dat 数据
using (StreamReader reader = new StreamReader(path))
{
int index = 0;
string line;
while ((line = reader.ReadLine()) != null)
{
// 首行不作为数据读取,直接跳过
if (index++ == 0) continue;

// 去除字符串中的多余空格
string[] item = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);

if (item.Length < 2) continue;

Paper paper = new Paper(item[0], Double.Parse(item[1]), Double.Parse(item[2]));
result.Add(paper);
}
}

String.Split 方法可以参考微软的这篇 如何在 C# 中使用 String.Split 分隔字符串StringSplitOptions.RemoveEmptyEntries 参数来排除返回数组中的任何空字符串。要对返回的集合进行更复杂的处理,可使用 LINQ 来处理结果序列。

CADNET & PLM 接口使用

GetCADApplication

获取 CAD 应用程序,首先明确我们的思路:

  1. 若 CAD 程序已经存在,则直接跳转至已打开的 CAD 程序
  2. 否则新建 CAD 应用程序
try
{
// 先尝试是否可以直接捕获打开的CAD应用程序
IGcadApplication app = Marshal.GetActiveObject("GStarCAD.Application") as IGcadApplication;

// 避免出现后台打开的情况
app.Visible = true;
}
catch(Exception exception)
{
// 捕获失败则新建CAD应用程序
app = new GcadApplicationClass();
app.Visible = true;
......
}

这里捕获用到了 System.Runtime.InteropServices 中的 Marshal.GetActiveObject() 方法。

注意 app.Visible 可以让后台的应用程序前置显示,在忘记关闭 Quit() 时会出现多个后台运行程序,在项目的前期测试造成了不少麻烦。

CloseFile

该命令会传入一个图纸路径 filePath,关闭该指定路径的图纸。

关闭图纸的业务场景相对简单,只需要判断两点:

  1. 当前 CAD 是否打开过任何图纸
  2. 在已打开过的图纸中是否有和 filePath 相对应的图纸

最后对找到的图纸进行关闭操作即可。

// 图纸数量小于等于 0 则未打开任何图纸
if (app.Documents.Count <= 0)
return;

// 遍历当前已打开图纸
foreach (GcadDocument doc in app.Documents)
{
if (string.Equals(doc.FullName, filePath, StringComparison.OrdinalIgnoreCase))
{
doc.Close();
return; // 关闭操作成功
}
}

return; // 未找到关闭图纸

需要注意的是这里需要用 doc.FullName 而非 doc.Name,以规避出现诸如 C:\test.dwgD:\test.dwgName 中均为 test.dwg 的末端图纸路径重名的情况。

OpenFile

与前面的关闭命令类似,该命令会传入一个图纸路径 filePath,若已经打开图纸则直接返回,否则打开该指定路径的图纸。

// 若当前已有打开图纸
if (app.Documents.Count > 0)
{
// 遍历当前已打开图纸
foreach (GcadDocument doc in app.Documents)
{
if (string.Equals(doc.FullName, path, StringComparison.OrdinalIgnoreCase))
return; // 图纸已经打开
}
}

// 没找到指定图纸则尝试打开该图纸
app.Documents.Open(path);

SaveFile

保存文件也是类似的,先传入一个图纸路径 filePath,若该图纸还未被打开则直接返回,否则保存该指定路径的图纸。

多的一种情况是 CAD 在图纸为只读模式的前提下逻辑上应禁止保存,这点注意一下即可。

// 图纸数量小于等于 0 则未打开任何图纸
if (appController.CadApp.Documents.Count <= 0)
return Message.SendMessage(false, "没有图纸被打开,保存失败");

// 遍历当前已打开图纸
foreach (GcadDocument doc in appController.CadApp.Documents)
{
if (string.Equals(doc.FullName, path, StringComparison.OrdinalIgnoreCase))
{
// 只读模式下的图纸不保存
if (doc.ReadOnly)
return Message.SendMessage(false, "指定图纸为只读模式,保存失败"); ;

doc.Save();
}
}

return; // 找不到要保存的指定图纸,保存失败;

GetOpeningFile

获取打开文件直接调用 ActiveDocument 即可。

但是当一张图纸新建之后并未保存(诸如 Drawing1.dwg 这样),需要系统变量 DWGTITLED 来判断是否为这种尚未保存的情况:

  • DWGTITLED = 0:Drawing has not been named
  • DWGTITLED = 1:Drawing has been named
if (appController.CadApp.Documents.Count <= 0)
return; // 没有图纸被打开

GcadDocument doc = appController.CadApp.ActiveDocument;
int titled = 0;
string dwgTitled = doc.GetVariable("DWGTITLED").ToString();

if (!int.TryParse(dwgTitled, out titled))
return; // DWGTITLED类型转换失败

return titled == 0 ? null : doc.FullName;

NewtonSoftJson自定义Convertor序列化Json

这个项目一个比较关键的过程就是去解析图纸数据并转换为 Json 格式,前面 PLM 接口负责解析数据,那么该如何将这些数据转换为 Json 呢?

官方样例

查阅官网的 Custom JsonConverter,给出了一个比较完整的示例:

public class KeysJsonConverter : JsonConverter
{
private readonly Type[] _types;

public KeysJsonConverter(params Type[] types)
{
_types = types;
}

public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
JToken t = JToken.FromObject(value);

if (t.Type != JTokenType.Object)
{
t.WriteTo(writer);
}
else
{
JObject o = (JObject)t;
IList<string> propertyNames = o.Properties().Select(p => p.Name).ToList();

o.AddFirst(new JProperty("Keys", new JArray(propertyNames)));

o.WriteTo(writer);
}
}

public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
throw new NotImplementedException("Unnecessary because CanRead is false. The type will skip the converter.");
}

public override bool CanRead
{
get { return false; }
}

public override bool CanConvert(Type objectType)
{
return _types.Any(t => t == objectType);
}
}

public class Employee
{
public string FirstName { get; set; }
public string LastName { get; set; }
public IList<string> Roles { get; set; }
}
Employee employee = new Employee
{
FirstName = "James",
LastName = "Newton-King",
Roles = new List<string>
{
"Admin"
}
};

string json = JsonConvert.SerializeObject(employee, Formatting.Indented, new KeysJsonConverter(typeof(Employee)));

Console.WriteLine(json);
// {
// "Keys": [
// "FirstName",
// "LastName",
// "Roles"
// ],
// "FirstName": "James",
// "LastName": "Newton-King",
// "Roles": [
// "Admin"
// ]
// }

Employee newEmployee = JsonConvert.DeserializeObject<Employee>(json, new KeysJsonConverter(typeof(Employee)));

Console.WriteLine(newEmployee.FirstName);
// James

抽象转换器类

首先继承 JsonConverter 构建抽象类 AbstractJsonConverter。该类主要用于实现 JsonConverter 的方法并留下 RewriteWriter() 作为真正重写转换方法的入口:

abstract public class AbstractJsonConverter : JsonConverter
{
protected readonly Type _type;
public AbstractJsonConverter(Type type)
{
_type = type;
}

public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
if (!(value is IDatabase db))
{
writer.WriteNull();
return;
}

RewriteWriter(writer, db, serializer);
}

public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
throw new NotImplementedException("Unnecessary because CanRead is false. The type will skip the converter.");
}

public override bool CanRead => false;
public override bool CanWrite => true;

public override bool CanConvert(Type objectType)
{
return typeof(IDatabase) == _type;
}

abstract protected void RewriteWriter(JsonWriter writer, IDatabase db, JsonSerializer serializer);
}

在这个过程中 CanConvert 方法的实现遇到了困难,因为该项目以 .exe 可执行程序为载体,所以调用诸如 IDatabase 这样的类型时获取到的 type 是COM组件而非具体的类型。

为了获取真正的类型我尝试过使用原生的类型获取方法、反射获取方法以及 VisualBasic 的内置方法,但均以失败告终,只好以官方示例为准构造时手动传入类型做判断。

维拓转换器类

真正的维拓转换器类我们只需要关心如何实现 RewriteWriter() 即可:

public class WeiTuoDBJsonConverter : AbstractJsonConverter
{
public WeiTuoDBJsonConverter(Type type) : base(type) { }

protected override void RewriteWriter(JsonWriter writer, IDatabase db, JsonSerializer serializer)
{
writer.WriteStartObject();
writer.WritePropertyName(dataText);
WriteDataBaseJson(writer, db, serializer);

writer.WriteEndObject();
}

void WriteDataBaseJson(JsonWriter writer, IDatabase db, JsonSerializer serializer)
{
if (db is null)
{
writer.WriteNull();
return;
}

writer.WriteStartArray();
// 解析图纸
foreach (var paperName in db.PaperNames)
{
IPaper paper = db[paperName];

writer.WriteStartObject();
writer.WritePropertyName(paperName);
writer.WriteStartObject();

WritePaperJson(writer, paper, serializer);

writer.WriteEndObject();
writer.WriteEndObject();
}

writer.WriteEndArray();
}

void WritePaperJson(JsonWriter writer, IPaper paper, JsonSerializer serializer)
{
if (paper is null)
{
writer.WriteNull();
return;
}

var resolver = serializer.ContractResolver as DefaultContractResolver;

ITitle title = paper?.Title;
writer.WritePropertyName(resolver == null
? nameof(title)
: resolver.GetResolvedPropertyName(nameof(title)));
WriteTitleJson(writer, title);

// 解析明细表
IBom bom = paper?.Bom;
writer.WritePropertyName(resolver == null
? nameof(bom)
: resolver.GetResolvedPropertyName(nameof(bom)));
writer.WriteStartArray();
WriteBomJson(writer, bom);
writer.WriteEndArray();

// 解析图框
IFrame frame = paper?.Frame;
writer.WritePropertyName(resolver == null
? nameof(frame)
: resolver.GetResolvedPropertyName(nameof(frame)));
WriteFrameJson(writer, frame, serializer);

......
}

void WriteTitleJson(JsonWriter writer, ITitle title)
{
if (title is null)
{
writer.WriteNull();
return;
}

writer.WriteStartObject();
foreach (string name in title.Names)
{
writer.WritePropertyName(name);
writer.WriteValue(title[name]);
}
writer.WriteEndObject();
}

void WriteBomJson(JsonWriter writer, IBom bom)
{
if (bom is null)
{
writer.WriteNull();
return;
}

foreach (var serialNumber in bom.SerialNumbers)
{
IBomRow bomRow = bom[serialNumber];

writer.WriteStartObject();
foreach (var name in bomRow.Names)
{
writer.WritePropertyName(name);
writer.WriteValue(bomRow[name]);
}
writer.WriteEndObject();
}
}

void WriteFrameJson(JsonWriter writer, IFrame frame, JsonSerializer serializer)
{
if (frame is null)
{
writer.WriteNull();
return;
}

writer.WriteStartObject();
......
writer.WriteEndObject();
}
}

解析命令使用

最后使用上就非常简单了,直接在序列化 SerializeObject() 中传入我们定制的转换器即可:

return JsonConvert.SerializeObject(db, new JsonSerializerSettings {
Formatting = Formatting.Indented,
ContractResolver = new CamelCasePropertyNamesContractResolver(),
Converters = new List<JsonConverter> { new WeiTuoDBJsonConverter(typeof(IDatabase)) }
  • Formatting.Indented 代表换行
  • CamelCasePropertyNamesContractResolver() 代表属性为驼峰命名法

转换器优化

虽然可以看到这种方式能够转换,但其实是手动实现解析的,我们真正希望的是可以自动识别并获取相应的转换器,完全通过成员关系序列化对象。

可能这种说法有些抽象,我还是以刚才的例子来讲。WeiTuoDBJsonConverter 可以解析 IDatabase,但如果后期需要单独解析 ITitle 或者 IBom 则需要重新实现对应的转换器类,现有的代码所有的解析都是作为函数的形式(如 WriteFrameJson())放在转换器类中,不够灵活并且可维护性差。我们希望的是每个类都有对应的转换器类,然后需要的时候将这些转换器进行组合。

具体转换器类

下面就以明细表为例来讲讲如何通过逐级实现转换器来达到目标。首先我们先梳理一下逻辑关系:

IFrame
|__ IBom
|__ IBomRow

如图所示,图框对象 IFrame 包含了 IBom 明细表对象,而 IBom 又包含了若干 IBomRow 明细表行对象。那么首先我们从最底层的 IBomRow 开始实现 BomRowConverter

/// <summary>
/// <see cref="IBomRow"/> data converter for <see cref="Newtonsoft.Json"/>
/// </summary>
public class BomRowConverter : JsonConverter<IBomRow>
{
/// <inheritdoc />
public override bool CanRead => false;

/// <inheritdoc />
public override bool CanWrite => true;

/// <inheritdoc />
public override void WriteJson(JsonWriter writer, IBomRow bomRow, JsonSerializer serializer)
{
if (bomRow is null)
{
writer.WriteNull();
return;
}

writer.WriteStartObject();
foreach (string name in bomRow.Names)
{
writer.WritePropertyName(name);
writer.WriteValue(bomRow[name]);
}

writer.WriteEndObject();
}

/// <inheritdoc />
public override IBomRow ReadJson(JsonReader reader, Type objectType, IBomRow existingValue,
bool hasExistingValue, JsonSerializer serializer)
{
throw new NotImplementedException();
}
}

一个简单的字典结构,遍历就可以序列化完成。BomConverter 的实现则略有不同:

/// <summary>
/// <see cref="IBom"/> data converter for <see cref="Newtonsoft.Json"/>
/// </summary>
public class BomConverter : JsonConverter<IBom>
{
/// <inheritdoc />
public override bool CanRead => false;

/// <inheritdoc />
public override bool CanWrite => true;

/// <inheritdoc />
public override void WriteJson(JsonWriter writer, IBom bom, JsonSerializer serializer)
{
if (bom is null)
{
writer.WriteNull();
return;
}

writer.WriteStartArray();
for (int r = 0; r < bom.RowCount; r++)
serializer.Serialize(writer, bom[r]);
writer.WriteEndArray();
}

/// <inheritdoc />
public override IBom ReadJson(JsonReader reader, Type objectType, IBom existingValue, bool hasExistingValue,
JsonSerializer serializer)
{
throw new NotImplementedException();
}
}

可以看到对于已有转换器的对象我们摒弃了先前的 writer,转而通过 serializer.Serialize() 的方式序列化 IBomRow 对象,FrameConverter 同理:

/// <summary>
/// <see cref="IFrame"/> data converter for <see cref="Newtonsoft.Json"/>
/// </summary>
public class FrameConverter : JsonConverter<IFrame>
{
/// <inheritdoc />
public override bool CanRead => false;

/// <inheritdoc />
public override bool CanWrite => true;

/// <inheritdoc />
public override void WriteJson(JsonWriter writer, IFrame frame, JsonSerializer serializer)
{
if (frame is null)
{
writer.WriteNull();
return;
}

var properties = typeof(IFrame).GetProperties(BindingFlags.Instance | BindingFlags.Public);

var resolver = serializer.ContractResolver as DefaultContractResolver;

writer.WriteStartObject();

foreach (PropertyInfo propertyInfo in properties)
{
writer.WritePropertyName(resolver?.GetResolvedPropertyName(propertyInfo.Name) ?? propertyInfo.Name);
writer.WriteValue(propertyInfo.GetValue(frame));
}

writer.WriteEndObject();
}

/// <inheritdoc />
public override IFrame ReadJson(JsonReader reader, Type objectType, IFrame existingValue, bool hasExistingValue,
JsonSerializer serializer)
{
throw new NotImplementedException();
}
}

通过反射技术获取属性,遍历属性来完成对子属性的定制序列化。

静态方法输出

后面的事情就水到渠成了,只需要将相应的转换器放给对应的打印函数即可:

/// <summary>
/// Structure data based on JSON extension class
/// </summary>
public static class StructureDataExtension
{
/// <summary>
/// Can convert the interface type with one of converter in <see cref="JsonSerializerSettings.Converters"/>
/// </summary>
/// <param name="interfaceType">The interface type of <see cref="AcmSymbb2"/></param>
/// <param name="settings">The <see cref="JsonSerializerSettings"/> object or null</param>
/// <returns>True if can convert, otherwise false</returns>
private static bool CanConvert(Type interfaceType, JsonSerializerSettings settings)
{
return settings != null && settings.Converters.Any(converter => converter.CanConvert(interfaceType));
}

/// <summary>
/// Serialize <see cref="AcmSymbb2"/> interface object
/// </summary>
/// <param name="obj">Interface object</param>
/// <param name="settings"><see cref="JsonSerializerSettings"/> object</param>
/// <param name="types">Interface type and converter type list</param>
/// <returns>JSON string</returns>
private static string SerializeObject(object obj, JsonSerializerSettings settings,
params (Type InterfaceType, Type ConverterType)[] types)
{
// priority: user defined > user default settings > builtin
settings = settings ?? new JsonSerializerSettings();
var defaultSettings = JsonConvert.DefaultSettings?.Invoke();

foreach ((Type interfaceType, Type converterType) in types)
{
if (CanConvert(interfaceType, settings) || CanConvert(interfaceType, defaultSettings))
continue;

settings.Converters.Add((JsonConverter)Activator.CreateInstance(converterType));
}

return JsonConvert.SerializeObject(obj, settings);
}

/// <summary>
/// <see cref="IBomRow"/> object dump to JSON string
/// </summary>
/// <param name="bomRow"><see cref="IBomRow"/> object</param>
/// <param name="settings"><see cref="JsonSerializerSettings"/> object</param>
/// <returns>JSON string</returns>
/// <exception cref="ArgumentNullException"></exception>
public static string Dump(this IBomRow bomRow, JsonSerializerSettings settings = null)
{
if (bomRow == null) throw new ArgumentNullException(nameof(bomRow));

return SerializeObject(bomRow, settings, (typeof(IBomRow), typeof(BomRowConverter)));
}

/// <summary>
/// <see cref="IBom"/> object dump to JSON string
/// </summary>
/// <param name="bom"><see cref="IBom"/> object</param>
/// <param name="settings"><see cref="JsonSerializerSettings"/> object</param>
/// <returns>JSON string</returns>
/// <exception cref="ArgumentNullException"></exception>
public static string Dump(this IBom bom, JsonSerializerSettings settings = null)
{
if (bom == null) throw new ArgumentNullException(nameof(bom));

return SerializeObject(bom, settings, (typeof(IBom), typeof(BomConverter)),
(typeof(IBomRow), typeof(BomRowConverter)));
}

/// <summary>
/// <see cref="IFrame"/> object dump to JSON string
/// </summary>
/// <param name="frame"><see cref="IFrame"/> object</param>
/// <param name="settings"><see cref="JsonSerializerSettings"/> object</param>
/// <returns>JSON string</returns>
/// <exception cref="ArgumentNullException"></exception>
public static string Dump(this IFrame frame, JsonSerializerSettings settings = null)
{
if (frame == null) throw new ArgumentNullException(nameof(frame));

return SerializeObject(frame, settings, (typeof(IFrame), typeof(FrameConverter)));
}
}

这里还需要考虑一下 JsonSerializerSettings 的优先级,用户 > 定制默认 > 系统默认,如果已经包含了可以解析的转换器就没有必要再放入相应的转换器了。