在构建数据处理流水线、规则引擎或任何需要动态编排任务的系统时,我们常常面临一个核心问题:如何让任务节点(函数)与工作流的拓扑结构解耦?
传统的做法是在节点内部硬编码输入数据的来源,例如直接引用上一个节点的输出键。这种方式虽然简单,但会导致节点与特定工作流强绑定,难以复用。一旦工作流调整,节点就必须修改。
Graphkey 是一个轻量级的动态工作流构建库,它通过引入占位符(Placeholder) 机制,将数据依赖关系从节点内部提升到工作流构建层。节点只需声明"我需要什么输入",而由构建器在组装时负责"从哪里获取"。这种设计让节点成为真正的"纯函数",实现了函数与工作流的完全解耦。
核心概念
Graphkey 的工作流由三个核心元素组成:
节点(Node)
节点是一个 IDictionary<string, object> 字典,必须包含一个名为 funcPipelineConfig 的配置字典。配置中定义了该节点的输入参数键列表(ParameterKeys)和输出结果键列表(ResultKeys)。节点还可以包含可选的 NodeId 和用于显示的 Label 或 Symbol。
占位符(Placeholder)
占位符是一个轻量级对象,描述对数据的引用意图,而不是具体的键名。例如 Param.Input("userId") 表示该参数来源于工作流的原始输入,键名为 "userId"。占位符只在构建阶段存在,最终会被解析为普通字符串。
构建器(DynamicWorkflowBuilder)和链(DynamicChainBuilder)
构建器负责管理所有节点和输入键,并提供链式 API 来顺序添加节点。每个链内部维护上一个节点的输出列表,使得 Param.Prev 能够正确解析。
核心功能:占位符如何实现解耦
Graphkey 提供了四种占位符类型,覆盖了常见的数据引用场景:

这些占位符可以灵活组合,并且支持链式索引器。例如,Param.From("nodeA")["resultX"] 明确表示引用节点 nodeA 的输出中键为 "resultX" 的那个值。
当你在链式构建器中添加节点时,构建器会立即解析节点 ParameterKeys 中的每个占位符:
Param.Input 将输入键注册到全局输入集合,并直接返回该键名。
Param.Prev 根据当前链的上一个节点输出列表解析为具体输出键。
Param.From 查询已添加节点的输出列表(通过节点 ID 映射),解析为具体输出键。
Param.Node 根据节点索引查询对应节点的输出列表,解析为具体输出键。
解析成功后,占位符被替换为实际的字符串键。最终生成的工作流中,所有节点的 ParameterKeys 都是普通字符串,不包含任何占位符对象。这意味着最终的工作流 JSON 是纯粹的数据结构,可以被任何下游系统消费,完全不知道占位符的存在。
快速示例
以下示例展示如何创建一个包含两个节点的工作流:第一个节点接收输入 "input1" 和 "input2",输出 "resultA";第二个节点引用第一个节点的输出(通过前序占位符)和另一个输入 "input3",输出 "finalResult"。
csharp
using Graphkey;
// 1. 创建构建器
var builder = new DynamicWorkflowBuilder();
// 2. 定义第一个节点(使用 Dictionary)
var node1 = new Dictionary<string, object>
{
["Label"] = "First Node",
["funcPipelineConfig"] = new Dictionary<string, object>
{
["ParameterKeys"] = new List<object> { Param.Input("input1"), Param.Input("input2") },
["ResultKeys"] = new List<string> { "resultA" }
},
["NodeId"] = "nodeA" // 可选,便于后续引用
};
// 3. 定义第二个节点(使用 ExpandoObject)
dynamic node2 = new ExpandoObject();
node2.Label = "Second Node";
node2.funcPipelineConfig = new ExpandoObject();
node2.funcPipelineConfig.ResultKeys = new List<string> { "finalResult" };
node2.funcPipelineConfig.ParameterKeys = new List<object>
{
Param.From("nodeA"), // 自动解析 nodeA 的唯一输出
Param.Input("input3")
};
// 4. 链式构建
builder.CreateChain("example")
.Then(node1)
.Then((object)node2) // 注意 dynamic 需显式转换为 object
.EndChain();
// 5. 构建并输出 JSON
string json = builder.BuildJson(format: true);
Console.WriteLine(json);
输出 JSON 示例:
{ "Label": "First Node", "funcPipelineConfig": { "ParameterKeys": \[ "input1", "input2" \], "ResultKeys": \[ "resultA"
}
},
{
"Label": "Second Node",
"funcPipelineConfig": {
"ParameterKeys": [ "resultA", "input3" ],
"ResultKeys": [ "finalResult" ]
}
}
]
可以看到,最终 JSON 中的 ParameterKeys 已经是普通字符串,占位符已完全消失。
占位符解析原理
Graphkey 的占位符解析发生在构建阶段,而不是运行时。这带来了几个好处:
提前验证:如果占位符引用了不存在的节点 ID、索引越界或键名错误,构建器会立即抛出详细异常,包含链名称和节点信息,避免运行时才发现问题。
性能开销为零:运行时工作流只包含纯字符串,无需解析任何对象。
可序列化:最终工作流可以轻松序列化为 JSON/XML 等格式,跨语言、跨系统传输。
解析过程中,构建器维护两个关键数据结构:
全局输入键集合(来自 Param.Input)。
节点 ID 到输出列表的映射(来自节点的 ResultKeys 和可选的 NodeId)。
当解析 Param.Prev 或 Param.From 时,构建器会查询这些集合,确保引用有效。如果引用不明确(例如上一个节点有多个输出但未指定索引或键),也会抛出异常,引导用户明确意图。
独立解析器工具
除了在构建时解析,Graphkey 还提供了一个静态类 PlaceholderResolver,允许你在构建后对已有的节点列表执行占位符解析。这在某些场景下很有用,例如从外部加载带有占位符的工作流定义,然后在内存中解析。
csharp
List nodes = LoadNodesFromJson(); // 节点中可能含有 Placeholder 对象
PlaceholderResolver.ResolvePlaceholders(nodes);
// 现在 nodes 中所有 ParameterKeys 都已解析为字符串
ResolvePlaceholders 会原地修改节点,并移除所有 NodeId,使输出与 Build(true) 的结果一致。
注意事项
节点必须实现 IDictionary<string, object>:Graphkey 默认使用字典操作,因此节点可以是 Dictionary<string, object>、ExpandoObject 或任何实现该接口的类型。
输出键必须全局唯一:在同一工作流中,所有节点的输出键(包括输入键)不能重复,除非你显式关闭验证(不推荐)。这保证了每个数据键在整个工作流中都有唯一来源。
节点 ID 作用域:节点 ID 在整个构建器范围内必须唯一,但在最终输出中通常会被移除,因此仅用于构建时引用。
占位符不能同时设置索引和键:Placeholder 对象禁止同时设置 Index 和 Key,否则会抛出异常。这是为了避免歧义。
链与链之间的依赖:如果需要引用其他链中的节点,必须使用 Param.From 或 Param.Node,因为 Param.Prev 只感知当前链。
总结
Graphkey 通过占位符机制,成功地将数据依赖关系从节点内部剥离出来,交由工作流构建器管理。节点只需声明自己的输入和输出键,而无需关心这些键来自何处。这种设计带来了巨大的灵活性:
节点可复用:同一个节点可以在不同工作流中,通过不同的占位符引用不同的数据源。
工作流可动态组装:可以在构建时决定节点之间的连接关系,甚至支持条件性连接。
验证前置:所有引用错误都在构建阶段被捕获,避免运行时崩溃。
无论你是构建数据处理流水线、规则引擎,还是复杂的编排系统,Graphkey 都能帮助你快速、安全地组装可维护的工作流定义。它的占位符机制,正是实现函数与工作流解耦的关键钥匙。
csharp
namespace Graphkey
{
using System;
using System.Collections;
using System.Collections.Generic;
using System.Dynamic;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using static GraphkeyConstants;
// ========== 常量定义 ==========
internal static class GraphkeyConstants
{
public const string FuncPipelineConfig = "funcPipelineConfig";
public const string NodeId = "NodeId";
public const string Symbol = "Symbol";
public const string Label = "Label";
public const string FuncName = "FuncName";
public const string ParameterKeys = "ParameterKeys";
public const string ResultKeys = "ResultKeys";
public const string UnknownDisplay = "unknown";
public const string DefaultResultKeyFormat = "{0}_Result";
}
// ========== 节点字典辅助类 ==========
internal static class NodeDictionaryHelper
{
public static IDictionary<string, object> GetConfig(object node) =>
(node as IDictionary<string, object>)?.TryGetValue(FuncPipelineConfig, out var cfg) == true
? cfg as IDictionary<string, object> ?? throw new ArgumentException($"funcPipelineConfig must be IDictionary<string, object>, got {cfg?.GetType()}")
: throw new ArgumentException("Node must contain a 'funcPipelineConfig' entry.");
public static string GetNodeId(object node) =>
(node as IDictionary<string, object>)?.TryGetValue(NodeId, out var id) == true ? id?.ToString() : null;
public static IEnumerable<object> GetParameterKeys(object node) =>
GetConfig(node).TryGetValue(ParameterKeys, out var keys)
? keys switch
{
IEnumerable<string> strEnum => strEnum.Cast<object>(),
IEnumerable<object> objEnum => objEnum,
_ => throw new ArgumentException($"ParameterKeys must be enumerable of strings or objects, got {keys?.GetType()}")
}
: Enumerable.Empty<object>();
public static IEnumerable<string> GetResultKeys(object node) =>
GetConfig(node).TryGetValue(ResultKeys, out var keys) && keys is IEnumerable<string> e ? e : Enumerable.Empty<string>();
public static void SetParameterKeys(object node, IEnumerable<object> keys)
{
var config = GetConfig(node);
config[ParameterKeys] = config.TryGetValue(ParameterKeys, out var existing)
? existing switch
{
List<string> _ => keys.Select(k => k?.ToString()).ToList(),
List<object> _ => keys.ToList(),
string[] _ => keys.Select(k => k?.ToString()).ToArray(),
object[] _ => keys.ToArray(),
_ => keys.ToList()
}
: keys.ToList();
}
public static string GetDisplayName(object node) =>
node is IDictionary<string, object> dict
? dict.TryGetValue(Symbol, out var sym) && sym != null ? sym.ToString()
: dict.TryGetValue(Label, out var label) && label != null ? label.ToString()
: UnknownDisplay
: UnknownDisplay;
public static object CloneNode(object node)
{
if (!(node is IDictionary<string, object> original))
throw new ArgumentException("Node must be an IDictionary<string, object> for cloning.");
var copy = new ExpandoObject();
var nodeCopyDict = (IDictionary<string, object>)copy;
foreach (var kv in original)
nodeCopyDict[kv.Key] = kv.Key == FuncPipelineConfig
? kv.Value is IDictionary<string, object> configDict ? CloneDictionary(configDict) : throw new ArgumentException("funcPipelineConfig must be an ExpandoObject.")
: kv.Value;
return copy;
}
private static object DeepCloneValue(object value)
{
if (value == null) return null;
if (value is IDictionary<string, object> dict) return CloneDictionary(dict);
if (value is IList list)
{
var type = list.GetType();
var elemType = type.IsArray ? type.GetElementType() : typeof(object);
var newList = type.IsArray ? Array.CreateInstance(elemType, list.Count) : (IList)Activator.CreateInstance(list.GetType(), list.Count);
for (int i = 0; i < list.Count; i++)
if (type.IsArray) ((Array)newList).SetValue(DeepCloneValue(list[i]), i);
else newList[i] = DeepCloneValue(list[i]);
return newList;
}
return value;
}
private static IDictionary<string, object> CloneDictionary(IDictionary<string, object> source)
{
var copy = new ExpandoObject();
var copyDict = (IDictionary<string, object>)copy;
foreach (var kv in source)
copyDict[kv.Key] = DeepCloneValue(kv.Value);
return copyDict;
}
}
// ========== 占位符接口(标记接口) ==========
public interface IPlaceholder { }
// ========== 占位符类型枚举 ==========
public enum PlaceholderKind
{
Input, // 输入占位符,仅包含 Key
Prev, // 前一个节点,可包含 Index 或 Key
NodeReference, // 指定节点ID,可包含 Index 或 Key
NodeIndex // 指定节点索引,可包含 Index 或 Key
}
// ========== 通用占位符类 ==========
public class Placeholder : IPlaceholder
{
public PlaceholderKind Kind { get; }
public string Key { get; }
public int? Index { get; }
public string NodeId { get; }
public int? NodeIndex { get; }
private Placeholder(PlaceholderKind kind, string key = null, int? index = null, string nodeId = null, int? nodeIndex = null)
{
Kind = kind;
Key = key;
Index = index;
NodeId = nodeId;
NodeIndex = nodeIndex;
}
// 静态工厂方法
public static Placeholder Input(string key) => new Placeholder(PlaceholderKind.Input, key: key);
public static Placeholder Prev() => new Placeholder(PlaceholderKind.Prev);
public static Placeholder PrevByIndex(int index) => new Placeholder(PlaceholderKind.Prev, index: index);
public static Placeholder PrevByKey(string key) => new Placeholder(PlaceholderKind.Prev, key: key);
public static Placeholder From(string nodeId) => new Placeholder(PlaceholderKind.NodeReference, nodeId: nodeId);
public static Placeholder From(string nodeId, int index) => new Placeholder(PlaceholderKind.NodeReference, nodeId: nodeId, index: index);
public static Placeholder From(string nodeId, string key) => new Placeholder(PlaceholderKind.NodeReference, nodeId: nodeId, key: key);
public static Placeholder Node(int nodeIndex) => new Placeholder(PlaceholderKind.NodeIndex, nodeIndex: nodeIndex);
// 链式索引器:通过索引进一步限定
public Placeholder this[int index]
{
get
{
if (Kind == PlaceholderKind.Prev || Kind == PlaceholderKind.NodeReference || Kind == PlaceholderKind.NodeIndex)
{
if (Key != null) throw new InvalidOperationException("Cannot use both index and key.");
if (Index.HasValue) throw new InvalidOperationException("Index already set.");
return new Placeholder(Kind, key: null, index: index, nodeId: NodeId, nodeIndex: NodeIndex);
}
throw new InvalidOperationException($"Indexer not supported for {Kind}.");
}
}
// 链式索引器:通过键进一步限定
public Placeholder this[string key]
{
get
{
if (Kind == PlaceholderKind.Prev || Kind == PlaceholderKind.NodeReference || Kind == PlaceholderKind.NodeIndex)
{
if (Index.HasValue) throw new InvalidOperationException("Cannot use both index and key.");
if (Key != null) throw new InvalidOperationException("Key already set.");
return new Placeholder(Kind, key: key, index: null, nodeId: NodeId, nodeIndex: NodeIndex);
}
throw new InvalidOperationException($"Key indexer not supported for {Kind}.");
}
}
}
// ========== 静态入口类:生成各种占位符 ==========
public static class Param
{
public static Placeholder Prev => Placeholder.Prev();
public static Placeholder PrevByIndex(int index) => Placeholder.PrevByIndex(index);
public static Placeholder PrevByKey(string key) => Placeholder.PrevByKey(key);
public static Placeholder From(string nodeId) => Placeholder.From(nodeId);
public static Placeholder From(string nodeId, int index) => Placeholder.From(nodeId, index);
public static Placeholder From(string nodeId, string key) => Placeholder.From(nodeId, key);
public static Placeholder Node(int nodeIndex) => Placeholder.Node(nodeIndex);
public static Placeholder Input(string key) => Placeholder.Input(key);
}
// ========== 节点处理器接口 ==========
public interface INodeHandler
{
string GetNodeId(object node);
IEnumerable<object> GetParameterKeys(object node);
IEnumerable<string> GetResultKeys(object node);
void SetParameterKeys(object node, IEnumerable<object> keys);
string GetDisplayName(object node);
object CloneNode(object node);
}
// ========== 默认处理器 ==========
public class DefaultNodeHandler : INodeHandler
{
public string GetNodeId(object node) => NodeDictionaryHelper.GetNodeId(node);
public IEnumerable<object> GetParameterKeys(object node) => NodeDictionaryHelper.GetParameterKeys(node);
public IEnumerable<string> GetResultKeys(object node) => NodeDictionaryHelper.GetResultKeys(node);
public void SetParameterKeys(object node, IEnumerable<object> keys) => NodeDictionaryHelper.SetParameterKeys(node, keys);
public string GetDisplayName(object node) => NodeDictionaryHelper.GetDisplayName(node);
public object CloneNode(object node) => NodeDictionaryHelper.CloneNode(node);
}
// ========== 通用工作流构建器 ==========
public class DynamicWorkflowBuilder
{
internal readonly List<object> _nodes = new List<object>();
private readonly HashSet<string> _nodeIds = new HashSet<string>();
private readonly HashSet<string> _inputKeys = new HashSet<string>();
private readonly Dictionary<string, List<string>> _nodeIdToResultKeys = new Dictionary<string, List<string>>();
private bool _validateOnBuild = true;
private readonly INodeHandler _handler;
public DynamicWorkflowBuilder(INodeHandler handler = null) => _handler = handler ?? new DefaultNodeHandler();
public DynamicWorkflowBuilder WithValidation(bool validate) { _validateOnBuild = validate; return this; }
public DynamicChainBuilder CreateChain(string chainName = null) => new DynamicChainBuilder(this, _handler, chainName);
internal void AddNode(object node, IEnumerable<string> resultKeys, string nodeId)
{
if (!string.IsNullOrEmpty(nodeId))
{
if (!_nodeIds.Add(nodeId))
throw new InvalidOperationException($"Duplicate node ID '{nodeId}'.");
_nodeIdToResultKeys[nodeId] = resultKeys.ToList();
}
_nodes.Add(node);
}
internal void AddInputKey(string key)
{
if (string.IsNullOrEmpty(key)) throw new ArgumentException("Input key cannot be null or empty.");
_inputKeys.Add(key);
}
internal bool TryGetNodeOutputs(string nodeId, out List<string> outputs) => _nodeIdToResultKeys.TryGetValue(nodeId, out outputs);
internal IReadOnlyList<object> GetNodes() => _nodes.AsReadOnly();
public List<object> Build(bool stripNodeIds = true)
{
if (_validateOnBuild) ValidateWorkflow();
if (stripNodeIds)
foreach (var node in _nodes.OfType<IDictionary<string, object>>())
node.Remove(NodeId);
return _nodes;
}
private void ValidateWorkflow()
{
var knownKeys = new HashSet<string>(_inputKeys);
int i = 0;
foreach (var node in _nodes)
{
string display = _handler.GetDisplayName(node) ?? $"Node_{i}";
var paramObjects = _handler.GetParameterKeys(node)?.ToList() ?? new List<object>();
var paramKeys = new List<string>();
foreach (var p in paramObjects)
{
if (p == null) throw new InvalidOperationException($"Parameter key cannot be null in node '{display}'.");
if (!(p is string s)) throw new InvalidOperationException($"Parameter key must be a string, got {p.GetType()} in node '{display}'.");
if (string.IsNullOrEmpty(s)) throw new InvalidOperationException($"Parameter key cannot be empty in node '{display}'.");
paramKeys.Add(s);
}
string unknown = paramKeys.FirstOrDefault(k => !knownKeys.Contains(k));
if (unknown != null)
throw new InvalidOperationException($"Parameter key '{unknown}' in node '{display}' is not defined.");
var resultKeys = _handler.GetResultKeys(node)?.ToList() ?? new List<string>();
foreach (var key in resultKeys)
{
if (string.IsNullOrEmpty(key)) throw new InvalidOperationException($"Output key cannot be null or empty in node '{display}'.");
if (knownKeys.Contains(key)) throw new InvalidOperationException($"Output key '{key}' in node '{display}' is already defined.");
}
foreach (var key in resultKeys) knownKeys.Add(key);
i++;
}
}
public string BuildJson(bool format = true) =>
System.Text.Json.JsonSerializer.Serialize(Build(true), new JsonSerializerOptions { WriteIndented = format, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping });
}
// ========== 链式构建器 ==========
public class DynamicChainBuilder
{
private readonly DynamicWorkflowBuilder _parent;
private readonly INodeHandler _handler;
private readonly string _chainName;
private List<string> _previousOutputKeys = new List<string>();
private readonly HashSet<string> _chainNodeIds = new HashSet<string>();
public DynamicChainBuilder(DynamicWorkflowBuilder parent, INodeHandler handler, string chainName)
{
_parent = parent ?? throw new ArgumentNullException(nameof(parent));
_handler = handler ?? throw new ArgumentNullException(nameof(handler));
_chainName = chainName;
}
public DynamicChainBuilder Then(object node)
{
var nodeCopy = _handler.CloneNode(node);
var rawParams = _handler.GetParameterKeys(nodeCopy)?.ToList() ?? new List<object>();
var resolvedParams = new List<object>();
for (int i = 0; i < rawParams.Count; i++)
{
try
{
var resolved = ResolveParameter(rawParams[i], nodeCopy);
if (resolved == null) throw new InvalidOperationException($"Resolved parameter at index {i} is null.");
resolvedParams.Add(resolved);
}
catch (Exception ex)
{
throw new InvalidOperationException(FormatMessage($"Error resolving parameter at index {i} in node '{GetDisplayName(nodeCopy)}': {ex.Message}"), ex);
}
}
ValidateKeysNoNullOrEmpty(resolvedParams.Cast<string>(), "Parameter", nodeCopy);
_handler.SetParameterKeys(nodeCopy, resolvedParams);
var resultKeys = _handler.GetResultKeys(nodeCopy)?.ToList() ?? throw new ArgumentException(FormatMessage("Node must define result keys."));
ValidateKeysNoNullOrEmpty(resultKeys, "Result", nodeCopy);
if (resultKeys.Count != resultKeys.Distinct().Count())
throw new ArgumentException(FormatMessage($"Output keys cannot contain duplicates. Node: {GetDisplayName(nodeCopy)}"));
string nodeId = _handler.GetNodeId(nodeCopy);
if (!string.IsNullOrEmpty(nodeId) && !_chainNodeIds.Add(nodeId))
throw new InvalidOperationException(FormatMessage($"Duplicate node ID '{nodeId}' in the same chain."));
_parent.AddNode(nodeCopy, resultKeys, nodeId);
_previousOutputKeys = resultKeys;
return this;
}
public DynamicWorkflowBuilder EndChain() => _parent;
private string FormatMessage(string msg) => string.IsNullOrEmpty(_chainName) ? msg : $"[Chain '{_chainName}'] {msg}";
private string GetDisplayName(object node) => _handler.GetDisplayName(node) ?? "unknown";
private void ValidateKeysNoNullOrEmpty(IEnumerable<string> keys, string keyType, object node)
{
int idx = 0;
foreach (var key in keys)
if (string.IsNullOrEmpty(key))
throw new ArgumentException(FormatMessage($"{keyType} key at position {idx} cannot be null or empty. Node: {GetDisplayName(node)}"));
else idx++;
}
// 解析参数(支持字符串和通用占位符)
private string ResolveParameter(object param, object currentNode)
{
switch (param)
{
case string s:
return s;
case Placeholder p:
switch (p.Kind)
{
case PlaceholderKind.Input:
if (string.IsNullOrEmpty(p.Key))
throw new ArgumentException(FormatMessage("Input key cannot be empty."));
_parent.AddInputKey(p.Key);
return p.Key;
case PlaceholderKind.Prev:
return ResolvePrev(p, currentNode);
case PlaceholderKind.NodeReference:
return ResolveNodeRef(p, currentNode);
case PlaceholderKind.NodeIndex:
return ResolveNodeIndex(p, currentNode);
default:
throw new ArgumentException(FormatMessage($"Unsupported placeholder kind: {p.Kind}"));
}
default:
throw new ArgumentException(FormatMessage($"Parameter must be string or Placeholder, got {param?.GetType()}"));
}
}
private string ResolvePrev(Placeholder p, object currentNode)
{
if (p.Index.HasValue && !string.IsNullOrEmpty(p.Key))
throw new InvalidOperationException(FormatMessage("Placeholder cannot have both Index and Key."));
if (_previousOutputKeys == null || _previousOutputKeys.Count == 0)
throw new InvalidOperationException(FormatMessage($"Cannot reference previous node because it has no outputs. Node: {GetDisplayName(currentNode)}"));
if (!p.Index.HasValue && string.IsNullOrEmpty(p.Key))
{
if (_previousOutputKeys.Count == 1)
return _previousOutputKeys.First();
throw new InvalidOperationException(FormatMessage($"Ambiguous reference to previous node: multiple outputs ({string.Join(", ", _previousOutputKeys)}). Use .Index or .Key."));
}
if (p.Index.HasValue)
{
int idx = p.Index.Value;
if (idx < 0 || idx >= _previousOutputKeys.Count)
throw new ArgumentOutOfRangeException(FormatMessage($"Index {idx} out of range. Available: {string.Join(", ", _previousOutputKeys)}"));
return _previousOutputKeys[idx];
}
if (_previousOutputKeys.Contains(p.Key))
return p.Key;
throw new ArgumentException(FormatMessage($"Key '{p.Key}' not found in previous node outputs. Available: {string.Join(", ", _previousOutputKeys)}"));
}
private string ResolveNodeRef(Placeholder p, object currentNode)
{
if (string.IsNullOrEmpty(p.NodeId))
throw new ArgumentException(FormatMessage("NodeReference placeholder must have NodeId."));
if (p.Index.HasValue && !string.IsNullOrEmpty(p.Key))
throw new InvalidOperationException(FormatMessage("Placeholder cannot have both Index and Key."));
if (!_parent.TryGetNodeOutputs(p.NodeId, out var outputs))
throw new ArgumentException(FormatMessage($"Node ID '{p.NodeId}' not found."));
if (outputs == null || outputs.Count == 0)
throw new InvalidOperationException(FormatMessage($"Node '{p.NodeId}' has no outputs."));
if (!p.Index.HasValue && string.IsNullOrEmpty(p.Key))
{
if (outputs.Count == 1)
return outputs.First();
throw new InvalidOperationException(FormatMessage($"Ambiguous reference to node '{p.NodeId}': multiple outputs ({string.Join(", ", outputs)}). Use .Index or .Key."));
}
if (p.Index.HasValue)
{
int idx = p.Index.Value;
if (idx < 0 || idx >= outputs.Count)
throw new ArgumentOutOfRangeException(FormatMessage($"Index {idx} out of range for node '{p.NodeId}'. Available: {string.Join(", ", outputs)}"));
return outputs[idx];
}
if (outputs.Contains(p.Key))
return p.Key;
throw new ArgumentException(FormatMessage($"Key '{p.Key}' not found in node '{p.NodeId}' outputs. Available: {string.Join(", ", outputs)}"));
}
private string ResolveNodeIndex(Placeholder p, object currentNode)
{
if (!p.NodeIndex.HasValue)
throw new ArgumentException(FormatMessage("NodeIndex placeholder must have NodeIndex."));
if (p.Index.HasValue && !string.IsNullOrEmpty(p.Key))
throw new InvalidOperationException(FormatMessage("Placeholder cannot have both Index and Key."));
var allNodes = _parent.GetNodes();
int total = allNodes.Count;
int nodeIdx = p.NodeIndex.Value < 0 ? total + p.NodeIndex.Value : p.NodeIndex.Value;
if (nodeIdx < 0 || nodeIdx >= total)
throw new ArgumentOutOfRangeException(FormatMessage($"Node index {p.NodeIndex} out of range. Total nodes: {total}"));
var outputs = _handler.GetResultKeys(allNodes[nodeIdx])?.ToList()
?? throw new InvalidOperationException(FormatMessage($"Node at index {p.NodeIndex} has no outputs."));
if (!p.Index.HasValue && string.IsNullOrEmpty(p.Key))
{
if (outputs.Count == 1)
return outputs.First();
throw new InvalidOperationException(FormatMessage($"Ambiguous reference to node at index {p.NodeIndex}: multiple outputs ({string.Join(", ", outputs)}). Use .Index or .Key."));
}
if (p.Index.HasValue)
{
int idx = p.Index.Value;
if (idx < 0 || idx >= outputs.Count)
throw new ArgumentOutOfRangeException(FormatMessage($"Index {idx} out of range for node at index {p.NodeIndex}. Available: {string.Join(", ", outputs)}"));
return outputs[idx];
}
if (outputs.Contains(p.Key))
return p.Key;
throw new ArgumentException(FormatMessage($"Key '{p.Key}' not found in node at index {p.NodeIndex} outputs. Available: {string.Join(", ", outputs)}"));
}
private IEnumerable<string> GetAllNodeIds() =>
_parent.GetNodes().Select(n => _handler.GetNodeId(n)).Where(id => !string.IsNullOrEmpty(id));
}
// ========== 通用占位符解析器(独立工具) ==========
public static class PlaceholderResolver
{
public static string Resolve(
IPlaceholder placeholder,
IReadOnlyList<string> previousOutputs,
IReadOnlyDictionary<string, IReadOnlyList<string>> nodeIdToOutputs,
IReadOnlyList<IReadOnlyList<string>> allNodeOutputs)
{
if (!(placeholder is Placeholder p))
throw new NotSupportedException($"Unsupported placeholder type: {placeholder.GetType()}");
switch (p.Kind)
{
case PlaceholderKind.Input:
return p.Key ?? throw new InvalidOperationException("Input placeholder must have a key.");
case PlaceholderKind.Prev:
return ResolvePrev(p, previousOutputs);
case PlaceholderKind.NodeReference:
return ResolveNodeRef(p, nodeIdToOutputs);
case PlaceholderKind.NodeIndex:
return ResolveNodeIndex(p, allNodeOutputs);
default:
throw new NotSupportedException($"Unsupported placeholder kind: {p.Kind}");
}
}
private static string ResolvePrev(Placeholder p, IReadOnlyList<string> previousOutputs)
{
if (previousOutputs == null || previousOutputs.Count == 0)
throw new InvalidOperationException("Cannot reference previous node because it has no outputs.");
if (!p.Index.HasValue && string.IsNullOrEmpty(p.Key))
{
if (previousOutputs.Count == 1)
return previousOutputs[0];
throw new InvalidOperationException($"Ambiguous reference to previous node: multiple outputs ({string.Join(", ", previousOutputs)}). Use .Index or .Key.");
}
if (p.Index.HasValue && !string.IsNullOrEmpty(p.Key))
throw new InvalidOperationException("Placeholder cannot have both Index and Key.");
if (p.Index.HasValue)
{
int idx = p.Index.Value;
if (idx < 0 || idx >= previousOutputs.Count)
throw new ArgumentOutOfRangeException(nameof(p.Index), $"Index {idx} out of range. Available: {string.Join(", ", previousOutputs)}");
return previousOutputs[idx];
}
if (previousOutputs.Contains(p.Key))
return p.Key;
throw new KeyNotFoundException($"Key '{p.Key}' not found in previous node outputs. Available: {string.Join(", ", previousOutputs)}");
}
private static string ResolveNodeRef(Placeholder p, IReadOnlyDictionary<string, IReadOnlyList<string>> nodeIdToOutputs)
{
if (string.IsNullOrEmpty(p.NodeId))
throw new ArgumentException("NodeReference placeholder must have NodeId.");
if (!nodeIdToOutputs.TryGetValue(p.NodeId, out var outputs))
throw new KeyNotFoundException($"Node ID '{p.NodeId}' not found.");
if (outputs == null || outputs.Count == 0)
throw new InvalidOperationException($"Node '{p.NodeId}' has no outputs.");
if (!p.Index.HasValue && string.IsNullOrEmpty(p.Key))
{
if (outputs.Count == 1)
return outputs[0];
throw new InvalidOperationException($"Ambiguous reference to node '{p.NodeId}': multiple outputs ({string.Join(", ", outputs)}). Use .Index or .Key.");
}
if (p.Index.HasValue && !string.IsNullOrEmpty(p.Key))
throw new InvalidOperationException("Placeholder cannot have both Index and Key.");
if (p.Index.HasValue)
{
int idx = p.Index.Value;
if (idx < 0 || idx >= outputs.Count)
throw new ArgumentOutOfRangeException(nameof(p.Index), $"Index {idx} out of range for node '{p.NodeId}'. Available: {string.Join(", ", outputs)}");
return outputs[idx];
}
if (outputs.Contains(p.Key))
return p.Key;
throw new KeyNotFoundException($"Key '{p.Key}' not found in node '{p.NodeId}' outputs. Available: {string.Join(", ", outputs)}");
}
private static string ResolveNodeIndex(Placeholder p, IReadOnlyList<IReadOnlyList<string>> allNodeOutputs)
{
if (!p.NodeIndex.HasValue)
throw new ArgumentException("NodeIndex placeholder must have NodeIndex.");
int total = allNodeOutputs.Count;
int nodeIdx = p.NodeIndex.Value < 0 ? total + p.NodeIndex.Value : p.NodeIndex.Value;
if (nodeIdx < 0 || nodeIdx >= total)
throw new ArgumentOutOfRangeException(nameof(p.NodeIndex), $"Node index {p.NodeIndex} out of range. Total nodes: {total}");
var outputs = allNodeOutputs[nodeIdx];
if (outputs == null || outputs.Count == 0)
throw new InvalidOperationException($"Node at index {p.NodeIndex} has no outputs.");
if (!p.Index.HasValue && string.IsNullOrEmpty(p.Key))
{
if (outputs.Count == 1)
return outputs[0];
throw new InvalidOperationException($"Ambiguous reference to node at index {p.NodeIndex}: multiple outputs ({string.Join(", ", outputs)}). Use .Index or .Key.");
}
if (p.Index.HasValue && !string.IsNullOrEmpty(p.Key))
throw new InvalidOperationException("Placeholder cannot have both Index and Key.");
if (p.Index.HasValue)
{
int idx = p.Index.Value;
if (idx < 0 || idx >= outputs.Count)
throw new ArgumentOutOfRangeException(nameof(p.Index), $"Index {idx} out of range for node at index {p.NodeIndex}. Available: {string.Join(", ", outputs)}");
return outputs[idx];
}
if (outputs.Contains(p.Key))
return p.Key;
throw new KeyNotFoundException($"Key '{p.Key}' not found in node at index {p.NodeIndex} outputs. Available: {string.Join(", ", outputs)}");
}
// 对节点列表执行占位符解析(直接修改传入的节点)
public static void ResolvePlaceholders(List<object> nodes)
{
// 构建节点ID到输出列表的映射,以及所有节点输出列表(按顺序)
var nodeIdToOutputs = new Dictionary<string, IReadOnlyList<string>>();
var allNodeOutputs = new List<IReadOnlyList<string>>();
foreach (var nodeObj in nodes)
{
var node = (IDictionary<string, object>)nodeObj;
string nodeId = node.TryGetValue("NodeId", out var idObj) ? idObj?.ToString() : null;
var config = (IDictionary<string, object>)node["FuncPipelineConfig"];
var resultKeys = config.TryGetValue("ResultKeys", out var rk) ? rk as IList<string> : null;
if (resultKeys == null) resultKeys = new List<string>();
var outputs = resultKeys.Cast<string>().ToList().AsReadOnly();
allNodeOutputs.Add(outputs);
if (!string.IsNullOrEmpty(nodeId))
nodeIdToOutputs[nodeId] = outputs;
}
// 解析每个节点的参数占位符
IReadOnlyList<string> previousOutputs = new List<string>().AsReadOnly();
for (int i = 0; i < nodes.Count; i++)
{
var nodeObj = nodes[i];
var node = (IDictionary<string, object>)nodeObj;
var config = (IDictionary<string, object>)node["FuncPipelineConfig"];
var paramKeys = config.TryGetValue("ParameterKeys", out var pk) ? pk as IList<object> : null;
if (paramKeys != null)
{
var resolvedParams = new List<object>();
foreach (var param in paramKeys)
{
if (param is string str)
{
resolvedParams.Add(str);
}
else if (param is IPlaceholder placeholder)
{
string resolved = Resolve(
placeholder,
previousOutputs,
nodeIdToOutputs,
allNodeOutputs);
resolvedParams.Add(resolved);
}
else
{
throw new InvalidOperationException($"Unexpected parameter type: {param?.GetType()}");
}
}
config["ParameterKeys"] = resolvedParams;
}
previousOutputs = allNodeOutputs[i];
}
// 移除所有节点的 NodeId(如果需要)
foreach (var nodeObj in nodes)
{
var node = (IDictionary<string, object>)nodeObj;
node.Remove("NodeId");
}
}
}
}