🔌 CYLDConverter 插件开发教程

🛠️ 开发者版本说明: 本教程基于 CYLDConverterDev(开发版),专为插件开发调试设计。 开发版 跳过签名验证,支持 热重载(菜单 [3]),可快速迭代,无需重启主程序。

📋 目录

  1. 开发环境要求
  2. 项目结构与 .csproj 配置
  3. 插件接口规范
  4. 共享库与依赖约定 ⚠️
  5. 资源文件搜索(FindResourceFile 模式)
  6. 进度报告接口
  7. 完整实现示例
  8. 部署与目录结构
  9. 热重载开发流程
  10. 调试与错误排查
  11. 迁移到正式版
  12. 插件模板(可直接复制)

1. 开发环境要求

项目要求
运行时.NET 8.0(Runtime 或 SDK)
编译目标net8.0
开发工具Visual Studio 2022+ / Rider / VS Code + C# Dev Kit
项目类型Class Library(类库)
程序集名称必须为 Main(见第 2 节)

2. 项目结构与 .csproj 配置

2.1 创建类库项目

Shellbash
dotnet new classlib -n MyPlugin
cd MyPlugin

2.2 配置 .csproj 关键

⚠️ AssemblyName 必须设为 Main
主程序在每个插件子目录中寻找的文件名固定为 Main.dll。 如果 AssemblyName 不是 Main,编译产物名称不对,插件将无法被加载。
MyPlugin.csprojxml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <OutputType>Library</OutputType>
    <!-- ★ 必须为 Main,编译输出才是 Main.dll ★ -->
    <AssemblyName>Main</AssemblyName>
    <RootNamespace>MyPlugin</RootNamespace>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <!-- 不生成独立 AssemblyInfo,避免与接口库冲突 -->
    <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
  </PropertyGroup>

  <ItemGroup>
    <!-- 引用主程序提供的接口库(不要复制到输出目录)-->
    <Reference Include="ConverterPlugins">
      <HintPath>..\CYLDConverterDev\ConverterPlugins.dll</HintPath>
      <Private>false</Private>
    </Reference>
  </ItemGroup>

  <!-- 如需 Newtonsoft.Json,同样设为不复制 -->
  <!--
  <ItemGroup>
    <PackageReference Include="Newtonsoft.Json" Version="13.*">
      <ExcludeAssets>runtime</ExcludeAssets>
    </PackageReference>
  </ItemGroup>
  -->

</Project>
💡 HintPath 说明: ConverterPlugins.dll 由主程序(CYLDConverterDev.exe)提供,位于 exe 同级目录。 开发时将该路径指向本地副本即可;正式发布时无需随插件一起打包。

3. 插件接口规范

3.1 必需接口:IConverterPlugin

IConverterPlugin(接口定义参考)C#
public interface IConverterPlugin
{
    /// <summary>插件唯一名称,不能为空</summary>
    string PluginName { get; }

    /// <summary>插件简介(显示在插件列表)</summary>
    string PluginInfo { get; }

    /// <summary>作者</summary>
    string Author { get; }

    /// <summary>版本号,如 "1.0.0"</summary>
    string Version { get; }

    /// <summary>详细描述(可多行)</summary>
    string DetailedDescription { get; }

    /// <summary>创建/更新时间,格式随意</summary>
    string CreatedTime { get; }

    /// <summary>支持的输入扩展名,必须含点,如 ".litematic"</summary>
    string[] SupportedInputExtensions { get; }

    /// <summary>输出文件扩展名,必须含点,如 ".txt"</summary>
    string SupportedOutputExtension { get; }

    /// <summary>执行转换</summary>
    /// <param name="inputData">文件原始字节(byte[])</param>
    /// <param name="outputPath">输出文件的完整路径</param>
    /// <param name="pluginDirectory">本插件子目录的完整路径</param>
    /// <param name="options">用户配置的选项字典,或进度回调(见第 6 节)</param>
    /// <returns>true = 成功,false = 失败</returns>
    bool Convert(object inputData, string outputPath,
                 string pluginDirectory, object? options);
}
属性 / 方法是否必需说明
PluginName必需非空字符串,全局唯一
SupportedInputExtensions必需非空数组,如 new[]{".txt"}
SupportedOutputExtension必需单个扩展名
Convert(...)必需核心转换逻辑
PluginInfo / Author / Version可选显示用,可返回空字符串
IPluginOptionsProvider可选需要用户输入参数时实现

3.2 可选接口:IPluginOptionsProvider

实现此接口后,主程序会在转换前逐条提示用户输入参数,并将结果以 Dictionary<string, object> 形式传入 Convertoptions 参数。

IPluginOptionsProviderC#
public interface IPluginOptionsProvider
{
    List<PluginOption>? GetOptions();
}

public class PluginOption
{
    public string Key          { get; set; } = "";      // 字典键名
    public string DisplayName  { get; set; } = "";      // 交互提示名
    public string Description  { get; set; } = "";      // 附加说明
    public string DefaultValue { get; set; } = "";      // 默认值(字符串形式)
    public string ValueType    { get; set; } = "string";// int / float / bool / string
    public bool   IsRequired   { get; set; } = false;   // 是否必填
}

主程序支持的 ValueType 及对应 C# 类型:

ValueType 字符串字典值 C# 类型接受的用户输入
stringstring任意文本
int / integerint整数
float / singlefloat小数
doubledouble小数
bool / booleanbooltrue/false/yes/no/1/0/on/off

4. 共享库与依赖约定 重要

⚠️ 以下两个程序集由主程序统一提供,插件目录中绝对不能放这两个文件: 若插件目录中存在这两个文件,AssemblyLoadContext 会将其作为独立程序集加载,导致 类型不匹配异常"The type 'IConverterPlugin' exists in both..."),插件将加载失败。

4.1 .csproj 中防止复制的写法

MyPlugin.csproj(防止共享库随插件输出)xml
<!-- 方式一:Reference 引用时设 Private=false -->
<Reference Include="ConverterPlugins">
  <HintPath>path\to\ConverterPlugins.dll</HintPath>
  <Private>false</Private>
</Reference>

<!-- 方式二:PackageReference 时排除 runtime 资产 -->
<PackageReference Include="Newtonsoft.Json" Version="13.*">
  <ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>

4.2 第三方依赖处理

除上述共享库外,其他第三方依赖(如 SharpCompress.dll)应放入插件子目录,DevPluginLoadContext 会自动从插件目录解析。

5. 资源文件搜索(FindResourceFile 模式)推荐

插件可能需要外部资源文件(如方块映射表 snbt_convert.txt、ID 映射 id.json 等)。 主程序向插件传入的 pluginDirectory 是该插件的子目录路径,但资源文件可能被放在 Plugins/ 根目录下供多个插件共享。

✅ 推荐做法:在插件内部实现 FindResourceFile() 辅助方法,按以下顺序搜索:
  1. 插件子目录本身(pluginDirectory/filename
  2. 插件子目录的 out 子目录(pluginDirectory/out/filename
  3. 父目录(即 Plugins/ 根目录)
这样资源文件无论放在哪里都能被找到。
FindResourceFile 辅助方法(复制到你的插件中)C#
/// <summary>
/// 在插件目录及父目录中搜索资源文件。
/// 搜索顺序:pluginDirectory → pluginDirectory/out → pluginDirectory 的父目录
/// </summary>
private static string? FindResourceFile(string pluginDirectory, string fileName)
{
    // 1. 插件子目录
    string path = Path.Combine(pluginDirectory, fileName);
    if (File.Exists(path)) return path;

    // 2. 插件子目录的 out 子目录
    path = Path.Combine(pluginDirectory, "out", fileName);
    if (File.Exists(path)) return path;

    // 3. Plugins 根目录(父目录)
    string? parentDir = Path.GetDirectoryName(pluginDirectory);
    if (parentDir != null)
    {
        path = Path.Combine(parentDir, fileName);
        if (File.Exists(path)) return path;
    }

    return null;
}

// 使用示例:
// string? mappingFile = FindResourceFile(pluginDirectory, "id.json");
// if (mappingFile != null)
//     LoadMapping(mappingFile);
// else
//     Console.WriteLine("[WARN] id.json 未找到,将使用内置默认映射");

6. 进度报告接口

主程序会将进度回调 Action<int> 通过 options 参数传入(百分比 0–100)。 插件可选择支持此功能,以在控制台显示进度条(由主程序渲染)。

进度报告用法C#
public bool Convert(object inputData, string outputPath,
                    string pluginDirectory, object? options)
{
    // 尝试从 options 中取出进度报告回调
    Action<int>? reportProgress = null;
    if (options is Action<int> directCallback)
    {
        reportProgress = directCallback;
    }
    else if (options is Dictionary<string, object> dict
             && dict.TryGetValue("__progress__", out var cb)
             && cb is Action<int> dictCallback)
    {
        reportProgress = dictCallback;
    }

    // 在转换过程中调用:
    reportProgress?.Invoke(0);   // 开始
    // ... 处理 25% ...
    reportProgress?.Invoke(25);
    // ... 处理 50% ...
    reportProgress?.Invoke(50);
    // ... 完成 ...
    reportProgress?.Invoke(100);

    return true;
}
📝 说明:若不需要进度报告,只需忽略 options 中的回调即可,不影响正常运行。

7. 完整实现示例

以下示例展示一个将文本文件转换为带行号版本的插件,演示了所有关键模式:

LineNumberPlugin.csC#
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using ConverterPlugins;

namespace LineNumberPlugin
{
    public class LineNumberConverter : IConverterPlugin, IPluginOptionsProvider
    {
        // ── 基本信息 ────────────────────────────────────────────────────────
        public string PluginName            => "行号添加器";
        public string PluginInfo            => "为文本文件每行添加行号";
        public string Author                => "开发者";
        public string Version               => "1.0.0";
        public string CreatedTime           => "2026-04-25";
        public string DetailedDescription   => "读取文本文件,在每行前加上行号,支持自定义分隔符和起始行号。";
        public string[] SupportedInputExtensions  => new[] { ".txt", ".log", ".csv" };
        public string SupportedOutputExtension    => ".txt";

        // ── 插件选项 ────────────────────────────────────────────────────────
        public List<PluginOption>? GetOptions() => new()
        {
            new() {
                Key = "separator", DisplayName = "行号分隔符",
                Description = "行号与内容之间的分隔符,如 \": \" 或 \". \"",
                ValueType = "string", DefaultValue = ": ", IsRequired = false
            },
            new() {
                Key = "startLine", DisplayName = "起始行号",
                Description = "第一行的行号",
                ValueType = "int", DefaultValue = "1", IsRequired = false
            },
            new() {
                Key = "padWidth", DisplayName = "行号宽度",
                Description = "行号左补零至此宽度(0=不补零)",
                ValueType = "int", DefaultValue = "0", IsRequired = false
            }
        };

        // ── 核心转换 ────────────────────────────────────────────────────────
        public bool Convert(object inputData, string outputPath,
                            string pluginDirectory, object? options)
        {
            // 1. 取进度回调
            Action<int>? progress = options is Action<int> cb ? cb : null;

            try
            {
                // 2. 验证输入
                if (inputData is not byte[] bytes)
                {
                    Console.WriteLine("[ERROR] 输入数据类型错误,期望 byte[]");
                    return false;
                }

                progress?.Invoke(5);

                // 3. 解析选项
                var opts = options as Dictionary<string, object> ?? new();
                string sep   = opts.TryGetValue("separator", out var s) && s is string sv ? sv : ": ";
                int start    = opts.TryGetValue("startLine", out var st) && st is int si ? si : 1;
                int pad      = opts.TryGetValue("padWidth",  out var pw) && pw is int pi ? pi : 0;

                // 4. 读取并处理
                string text  = Encoding.UTF8.GetString(bytes);
                var lines    = text.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
                var sb       = new StringBuilder(bytes.Length + lines.Length * 6);

                progress?.Invoke(20);

                for (int i = 0; i < lines.Length; i++)
                {
                    string lineNum = pad > 0
                        ? (start + i).ToString().PadLeft(pad, '0')
                        : (start + i).ToString();
                    sb.Append(lineNum).Append(sep).AppendLine(lines[i]);

                    if (i % 1000 == 0)
                        progress?.Invoke(20 + (int)(i * 70.0 / lines.Length));
                }

                progress?.Invoke(90);

                // 5. 输出文件
                File.WriteAllText(outputPath, sb.ToString(), Encoding.UTF8);
                progress?.Invoke(100);

                Console.WriteLine($"[INFO] 已处理 {lines.Length} 行 -> {outputPath}");
                return true;
            }
            catch (Exception ex)
            {
                Console.WriteLine($"[ERROR] {PluginName} 转换异常: {ex.Message}");
                return false;
            }
        }
    }
}

8. 部署与目录结构

8.1 编译插件

Shellbash
# Release 编译(推荐)
dotnet build -c Release

# 输出:bin/Release/net8.0/Main.dll  ← 因为 AssemblyName=Main
# 将该目录下的 Main.dll(以及第三方依赖)复制到 Plugins/插件子目录/

8.2 标准目录结构

目录结构
CYLDConverterDev/                      # 开发版主程序目录
├── CYLDConverterDev.exe               # 主程序
├── ConverterPlugins.dll               # 接口库(主程序提供,勿放入插件目录)
├── Newtonsoft.Json.dll                # 若有(主程序提供,勿放入插件目录)
├── Logs/                              # 自动生成的日志目录
│
├── Plugins/                           # 插件根目录
│   │
│   ├── snbt_convert.txt               # 共享资源(可被多个插件通过 FindResourceFile 找到)
│   ├── id.json                        # 共享资源
│   │
│   ├── MyPlugin/                      # 插件子目录(名称任意)
│   │   ├── Main.dll                   # ★ 必须命名为 Main.dll ★
│   │   └── SomeDep.dll                # 第三方依赖(按需放置)
│   │
│   ├── LitematicConverter/
│   │   └── Main.dll
│   │
│   └── SchematicConverter/
│       └── Main.dll
│
└── 其他运行时文件...

8.3 文件清单

文件是否必需说明
Main.dll必需插件主程序集,名称固定
*.dll(第三方)按需第三方依赖,排除共享库
Main.dll.sig❌ 开发版不需要正式版签名文件
ConverterPlugins.dll❌ 禁止放入会导致类型冲突
Newtonsoft.Json.dll❌ 禁止放入主程序已提供
资源文件按需可放插件子目录或 Plugins/ 根目录

9. 热重载开发流程

🔄 开发版热重载: 修改插件代码后,无需重启 CYLDConverterDev.exe,只需:
  1. 重新编译插件(dotnet build
  2. 将新的 Main.dll 覆盖到对应插件目录
  3. 在主程序菜单选择 [3] 重新加载插件
主程序会卸载旧的 AssemblyLoadContext 并重新扫描所有插件目录。

推荐的自动化脚本(PowerShell)

deploy-plugin.ps1PowerShell
# 编译并部署到开发版目录,配合菜单 [3] 热重载使用
$pluginName = "MyPlugin"
$devDir     = "C:\path\to\CYLDConverterDev\Plugins\$pluginName"

dotnet build -c Release
if ($LASTEXITCODE -ne 0) { Write-Error "编译失败"; exit 1 }

New-Item -ItemType Directory -Force -Path $devDir | Out-Null
Copy-Item "bin\Release\net8.0\Main.dll" "$devDir\Main.dll" -Force

Write-Host "✓ 已部署到 $devDir" -ForegroundColor Green
Write-Host "  → 在主程序菜单选择 [3] 重新加载插件"

10. 调试与错误排查

10.1 日志位置

开发版以 DEBUG 级别记录所有插件加载和转换过程,日志文件位于 Logs/CYLDConverterDev_YYYY-MM-DD.log

10.2 常见错误及解决方案

错误现象根本原因解决方案
跳过: MyPlugin (缺少 Main.dll) AssemblyName 不是 Main,输出文件名不对 .csproj 中添加 <AssemblyName>Main</AssemblyName>
类型加载异常 / IConverterPlugin 不匹配 插件目录中存在 ConverterPlugins.dll 删除插件目录中的 ConverterPlugins.dll,并在 csproj 设 Private=false
未找到 IConverterPlugin 实现 插件类未实现接口,或命名空间冲突 检查 class MyConverter : IConverterPlugin 是否正确
重复插件名称,跳过 两个插件目录的 PluginName 属性返回相同值 修改其中一个插件的 PluginName
依赖项加载失败 (FileNotFoundException) 第三方 DLL 未放在插件目录 将依赖 DLL 复制到 Plugins/MyPlugin/ 目录
资源文件找不到 硬编码路径或未使用 FindResourceFile 使用第 5 节的 FindResourceFile() 辅助方法
转换失败,Convert 返回 false 插件内部逻辑异常 查看控制台输出和 Logs/ 日志;在 Convert 内 try/catch 并打印详细错误

10.3 开发版调试技巧

11. 迁移到正式版

1 在开发版完成测试 — 确保所有功能、边界条件、错误处理均已验证
2 清理调试代码 — 移除或关闭 DEBUG 级别的 Console.WriteLine 输出
3 Release 编译dotnet build -c Release,确认 bin/Release/net8.0/Main.dll 存在
4 申请签名 — 将 Main.dll 发送给管理员,获取 Main.dll.sig 签名文件
5 部署到正式版 — 将 Main.dllMain.dll.sig 及依赖 DLL 放入正式版 Plugins/<插件名>/
⚠️ 注意:正式版对签名验证使用 XOR 加密的 .sig 文件,开发版不验证签名。 插件在正式版没有 .sig 文件时无法加载

12. 插件模板(可直接复制)

下面是一个生产就绪的最简插件模板,涵盖所有最佳实践:

PluginTemplate.csprojxml
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <OutputType>Library</OutputType>
    <AssemblyName>Main</AssemblyName>         <!-- ★ 固定为 Main ★ -->
    <RootNamespace>PluginTemplate</RootNamespace>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
  </PropertyGroup>
  <ItemGroup>
    <Reference Include="ConverterPlugins">
      <HintPath>..\CYLDConverterDev\ConverterPlugins.dll</HintPath>
      <Private>false</Private>               <!-- ★ 不复制到输出目录 ★ -->
    </Reference>
  </ItemGroup>
</Project>
PluginTemplate.csC#
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using ConverterPlugins;

namespace PluginTemplate
{
    public class TemplateConverter : IConverterPlugin, IPluginOptionsProvider
    {
        // ── 1. 基本元数据 ────────────────────────────────────────────────────
        public string PluginName           => "我的插件";
        public string PluginInfo           => "简要说明(一行)";
        public string Author               => "作者名";
        public string Version              => "1.0.0";
        public string CreatedTime          => "2026-04-25";
        public string DetailedDescription  => "详细描述(可多行)";
        public string[] SupportedInputExtensions  => new[] { ".txt" };
        public string SupportedOutputExtension    => ".txt";

        // ── 2. 插件参数(无需参数时可删除此方法,并去掉 IPluginOptionsProvider)──
        public List<PluginOption>? GetOptions() => new()
        {
            new() {
                Key = "myParam", DisplayName = "我的参数",
                Description = "参数说明",
                ValueType = "string", DefaultValue = "默认值", IsRequired = false
            }
        };

        // ── 3. 核心转换 ──────────────────────────────────────────────────────
        public bool Convert(object inputData, string outputPath,
                            string pluginDirectory, object? options)
        {
            // 3.1 取进度回调(可选)
            Action<int>? progress = options is Action<int> cb ? cb : null;

            try
            {
                // 3.2 验证输入
                if (inputData is not byte[] bytes)
                {
                    Console.WriteLine("[ERROR] 输入数据类型错误");
                    return false;
                }

                progress?.Invoke(10);

                // 3.3 取选项
                var opts = options as Dictionary<string, object> ?? new();
                string myParam = opts.TryGetValue("myParam", out var v) && v is string s ? s : "默认值";

                // 3.4 查找资源文件(若需要)
                // string? resFile = FindResourceFile(pluginDirectory, "mapping.txt");

                // 3.5 转换逻辑(在此处实现你的核心逻辑)
                string input  = Encoding.UTF8.GetString(bytes);
                string output = $"参数: {myParam}\n\n{input}";

                progress?.Invoke(90);

                // 3.6 写出文件
                File.WriteAllText(outputPath, output, Encoding.UTF8);
                progress?.Invoke(100);

                return true;
            }
            catch (Exception ex)
            {
                Console.WriteLine($"[ERROR] {PluginName}: {ex.Message}");
                return false;
            }
        }

        // ── 辅助:资源文件搜索 ────────────────────────────────────────────────
        private static string? FindResourceFile(string pluginDirectory, string fileName)
        {
            string path = Path.Combine(pluginDirectory, fileName);
            if (File.Exists(path)) return path;

            path = Path.Combine(pluginDirectory, "out", fileName);
            if (File.Exists(path)) return path;

            string? parent = Path.GetDirectoryName(pluginDirectory);
            if (parent != null)
            {
                path = Path.Combine(parent, fileName);
                if (File.Exists(path)) return path;
            }

            return null;
        }
    }
}
⚠️ 重要提醒: 开发版仅用于开发和测试。在正式环境中部署插件时,必须使用正式版并附带有效的签名文件(Main.dll.sig)。
↑ 返回顶部  |  CYLDConverter 插件开发教程 · 开发者版本