插件化与Scriban模板引擎:打造可配置的HTTP协议中心 做平台的同学大概都经历过这种日子公司系统里的 HTTP 接口从几十个涨到几百个每个对接方都有自己的脾气。有的要 XML 报文有的要 JSON 签名有的是 OAuth2.0有的干脆就是裸 GET 往 URL 塞参数。更麻烦的是这些适配代码通常散落在各个业务项目里今天加一个上游字段明天换一个签名算法后天又多了个第三方服务要接入改来改去全是重复劳动而且谁都不敢动别人那部分逻辑。我一直在想能不能把“对接 HTTP 协议”这件事本身做成一个独立的基础设施所有上行请求按照一套约定进来由中心统一负责“翻译”成目标服务需要的协议格式再把响应统一“翻译”回去。落地之后就是标题里说的东西——基于插件化加 Scriban 模板引擎的 HTTP 协议中心。这篇文章把整个设计思路、关键代码和踩过的坑都整理出来给同样在做接口适配、网关集成或者多供应商接入的团队一个参考。1. 为什么要做协议中心接口适配的痛与破局1.1 那些散落在各处的“一次性适配代码”大部分团队的接口对接史其实是一部复制粘贴史。我见过一个中后台系统里面有将近 30 处代码都在做同一件事把内部业务参数拼成某个上游服务需要的报文调用 HTTP 接口再解析返回。签名算法换个版本要好几个项目同时改新接入一个渠道基本就是照着旧渠道的代码复制一份改改 URL 和字段名。这种做法的成本在一开始并不明显等到接口数量上来之后会集中爆发。第一是维护成本每个适配点的代码风格和异常处理方式都不一样出问题只能逐个排查。第二是接入成本新服务要评估、要开发、要联调哪怕只是接口风格不同也免不了一轮测试。第三是最隐蔽的就是“隐含协议”的扩散哪个字段必须大写、哪个日期格式必须带时区、哪个错误码才代表业务失败这些信息藏在代码里不翻源码根本不知道。1.2 协议中心的职责边界协议中心的定位不是一个普通的网关。网关做的是流量转发、路由、鉴权它关心的是“请求怎么从客户端到上游”协议中心关心的则是“业务语义怎么翻译成上游协议语义”而且要做到可配置、可插拔。也就是说调用方传进来的是一组与业务相关的键值参数协议中心负责根据协议描述文件把参数渲染成目标服务要求的结构。它内部至少应该包含几层能力协议注册与路由根据协议名找到对应的处理插件。模板渲染将请求参数按模板转换为 URL、Header、Body。响应规整从上游返回的报文里提取业务状态、数据字段和错误信息统一返回结构。扩展机制遇到特殊签名、加密逻辑时直接用插件代码处理而不是硬塞进模板。这样划分之后适配新接口的工作量就从“写一套代码”变成了“写一个插件/配一份模板”联调周期能压得很低。1.3 插件化与模板引擎的分工逻辑这个设计里容易让人困惑的是插件化和模板引擎的边界。我的原则很简单模板引擎处理“形状变化”插件处理“语义变化”。什么是形状变化就是同样的业务参数在不同上游服务里的字段名、嵌套层级、大小写风格不一样。这种差异用模板来描述最合适因为它本质上就是字符串变换。什么是语义变化比如某些平台要求请求体先做 AES 加密再把密文放到固定字段里或者某个老系统的签名规则是把所有参数按字典序拼接后做 MD5。这些是逻辑不是字符串替换必须交给插件代码实现。说白了模板引擎负责让协议中心能“配出”大部分常规 HTTP 接口插件机制负责兜住那些模板表达不了的特殊逻辑。两者配合才能覆盖真实环境里五花八门的协议。2. 插件化架构的设计与实现2.1 插件接口的约定与定义插件机制的第一步是把所有插件必须实现的能力抽象成接口。我把它收敛成三个部分协议名、元信息描述、执行入口。协议名是路由 key元信息描述告诉协议中心这个插件支持什么能力、需要哪些参数执行入口负责真正完成一次协议调用。核心接口定义大致是这样public interface IProtocolPlugin { // 协议唯一标识例如 http-generic、md5-sign、aes-encrypted string ProtocolName { get; } // 声明支持的配置项和默认值便于管理界面渲染 ProtocolMetadata Metadata { get; } // 执行一次协议转发 TaskProtocolResult ExecuteAsync( ProtocolContext context, CancellationToken cancellationToken); }ProtocolContext 是协议中心和插件之间的数据载体包含用户的业务参数、模板源码、可用的 HttpClient、日志器等。这里有一个关键设计插件不直接 new HttpClient而是从 context 里拿。这样做是为了让协议中心统一管理连接池、超时和代理设置插件只管业务逻辑。public sealed class ProtocolContext { public required string ProtocolName { get; init; } public required IReadOnlyDictionarystring, object? Parameters { get; init; } public required string TemplateSource { get; init; } public required HttpClient Client { get; init; } public required ILogger Logger { get; init; } public required TemplateCache TemplateCache { get; init; } }2.2 用 AssemblyLoadContext 做动态加载与隔离.NET 生态里做插件加载绕不开 AssemblyLoadContext简称 ALC。我这里用的是可回收的 ALC也就是创建时传isCollectible: true。这样做的好处有两个一是插件程序集之间的依赖互不污染二是支持热卸载。插件目录结构我建议固定下来比如每个插件一个子目录目录里放 manifest.json、插件程序集和它私有引用的依赖。internal sealed class PluginAssemblyLoadContext : AssemblyLoadContext { private readonly AssemblyDependencyResolver _resolver; public PluginAssemblyLoadContext(string pluginPath) : base(isCollectible: true) { _resolver new AssemblyDependencyResolver(pluginPath); } protected override Assembly? Load(AssemblyName assemblyName) { string? path _resolver.ResolveAssemblyToPath(assemblyName); return path ! null ? LoadFromAssemblyPath(path) : null; } }关键点是AssemblyDependencyResolver。它会把“插件目录里存在的 dll”优先解析给插件自己解析不到再回退到宿主的默认加载逻辑。这样就解决了插件和宿主引用同一组件的不同版本时的冲突问题。常见的坑是插件把整个宿主框架的依赖也复制了一份导致 ALC 里同时存在两个版本的同一个类型后面我会专门写。2.3 插件扫描、注册与热更新插件加载器的工作流程不难扫描根目录下的子目录读取 manifest.json用 ALC 加载程序集反射找到实现 IProtocolPlugin 的类型并实例化最后放进协议路由表。public void LoadPluginFromDirectory(string directory) { var manifestPath Path.Combine(directory, manifest.json); var manifest JsonSerializer.DeserializePluginManifest( File.ReadAllText(manifestPath), JsonOptions); var assemblyPath Path.Combine(directory, manifest.PluginAssembly); var context new PluginAssemblyLoadContext(assemblyPath); var assembly context.LoadFromAssemblyPath(assemblyPath); foreach (var type in assembly.GetTypes() .Where(t !t.IsAbstract typeof(IProtocolPlugin).IsAssignableFrom(t))) { var instance (IProtocolPlugin)Activator.CreateInstance(type)!; _registry[instance.ProtocolName] new PluginEntry(instance, context); } }热更新怎么做最简单可靠的方案是“双目录轮换 标记卸载”。新版本插件先放到一个 stage 目录加载成功后做一个原子切换把旧的 entry 从路由表移除然后触发context.Unload()。注意 ALC 的 Unload 是异步的不会立刻释放需要等引用它的对象都被回收。所以在移除注册表项之后要主动把插件实例置 null再调用GC.Collect()和GC.WaitForPendingFinalizers()配合强制回收。这里有个很现实的经验不要在生产环境里频繁热更新插件因为 ALC 的卸载依赖 GC如果插件实例被某个长期存活的对象引用根本卸载不干净。更稳的做法是支持运维先摘流量再在低峰期更新。2.4 内置的“万能 HTTP 插件”实际落地时我发现80% 的 HTTP 接口都走同一种模式渲染 URL、渲染 Header、渲染 Body、解析响应。所以我没有要求每个协议都单独写插件而是内置了一个http-generic插件它读取协议模板配置用 Scriban 渲染请求再解析响应。特殊的协议才写独立插件。这样做最大的好处是接入成本极低。普通 HTTP 接口只需要协议中心的管理员配一份模板 JSON不需要写一行代码。独立插件只留给签名、加密、特殊鉴权这类场景。这个取舍非常关键它决定了协议中心是“配置驱动”的轻量系统而不是“每个协议写一个类”的代码堆。3. Scriban 模板引擎在协议描述中的深度应用3.1 模板在协议中心里到底承担什么角色在没有模板引擎之前协议描述如果是纯 JSON 映射遇到“日期格式化”“字段拼接”“条件分支”就很别扭。比如 URL 是https://api.com/v1/{category}/{id}?lang{lang}参数 id 可能是数字也可能是带前缀的字符串纯静态配置很难表达。引入 Scriban 之后协议模板就变成了“可编程的文本”。Scriban 的语法是 Liquid 风格模板里可以写变量、if 判断、for 循环甚至调用自定义函数但本质上它渲染出来的仍是字符串。这个定位非常合适协议描述是配置配置里不需要复杂的面向对象能力但需要足够灵活的表达能力。我建议把模板严格限制在“描述请求和响应的形状”不要在模板里写业务逻辑。比如来自上游的错误码到内部错误码的映射默认应该由插件代码完成。模板负责把参数渲染成请求报文、解析响应里的字段仅此而已。3.2 协议模板的 JSON 描述格式我设计的协议模板长这样{ schemaVersion: 1, request: { method: POST, url: {{baseUrl}}/api/v1/{{action}}?trace{{requestId}}, headers: { Content-Type: application/json, X-Timestamp: {{timestamp}}, X-Nonce: {{nonce}} }, body: {\keyword\:\{{keyword}}\,\page\:{{page}},\size\:{{size}}} }, response: { successCode: 0, codePath: code, messagePath: message, dataPath: data } }其中{{baseUrl}}、{{keyword}}这些占位符就是业务参数的入口。协议中心在接收到请求时把调用方传入的参数合并成一个大字典渲染模板时直接从这个字典取值。对于 body我见过不少团队直接用{{ }}渲染整段 JSON一旦某个字段值里含引号或换行容易把报文搞坏。所以我加了几个预置函数比如json_escape在模板里写成keyword:{{keyword | string.csharp_escape}}确保渲染出来的 JSON 始终合法。3.3 用 Scriban 渲染请求的完整写法Scriban 的模板解析可以提前做。模板应该只解析一次然后缓存起来每次渲染只是执行一次模板而不是重新解析。这是协议中心性能的关键之一。public static string Render(string templateSource, object? model) { var template TemplateCache.GetOrAdd(templateSource); if (template.HasErrors) { throw new TemplateParsingException(template.Messages); } var context BuildAndPushGlobal(model); try { return template.Render(context); } finally { context.PopGlobal(); } } private static TemplateContext BuildAndPushGlobal(object? model) { var context new TemplateContext { StrictVariables false, MemberRenamer MemberRenamers.LowerCamelCase, NewLine \n, LoopLimit 1000 }; context.EnableLoopLimit(); context.PushGlobal(model); return context; }这里有一个需要刻意注意的配置StrictVariables false。它的意思是渲染时如果变量不存在不抛异常而是渲染为空字符串。这个配置在排错时很迷惑人变量名拼错了不会立刻报错只会在报文字段里留下一个空值。所以我建议在调试阶段把它临时改为 true发布前再改回来。LoopLimit加上EnableLoopLimit()是防止恶意模板里写死循环把 CPU 打满。这是模板引擎做安全加固很重要的一个开关尤其是协议模板如果允许非管理员提交一定要开着。3.4 响应的模板化解析与字段规整响应解析我也用了模板化的思路但比请求简单。大多数 JSON 接口的响应都是{ code: 0, message: ok, data: {...} }这种结构协议模板里声明 codePath、messagePath、dataPath 就够了。对于个别字段需要转换逻辑的响应我允许在 response 里配置一个可选的fields列表字段值用模板表达式描述。比如上游返回的createTime是毫秒时间戳内部要求是yyyy-MM-dd HH:mm:ss就可以在模板里写fields: { createTime: {{response.data.createTime | date_format %Y-%m-%d %H:%M:%S}} }这是把响应临时转成一个对象后再用 Scriban 渲染一次。虽然性能有一点损耗但换来的是极高的灵活性而且只在真正需要转换的协议上开这个功能默认走纯路径提取。3.5 预编译缓存与并发控制模板缓存我用的是 ConcurrentDictionary 加锁的双重检查。为什么不用GetOrAdd因为它传入的 factory 在高并发下可能被执行多次模板解析虽然不算特别重但也没必要反复解析同一个模板。双重检查可以保证同一个模板源码只被解析一次。public sealed class TemplateCache { private readonly ConcurrentDictionarystring, Template _cache new(); private readonly object _gate new(); public Template GetOrAdd(string source) { if (_cache.TryGetValue(source, out var cached)) { return cached; } lock (_gate) { if (_cache.TryGetValue(source, out cached)) { return cached; } var template Template.Parse(source, source); if (template.HasErrors) { throw new InvalidOperationException( $Template parse failed: {string.Join(; , template.Messages.Select(m m.ToString()))}); } _cache.TryAdd(source, template); return template; } } public void Clear() { lock (_gate) { _cache.Clear(); } } }还有一点Scriban 的Template对象是线程安全可重入的同一个模板实例可以同时被多个线程渲染。所以缓存一个实例绝不会成为并发瓶颈真正要小心的是不要共享同一个 TemplateContext因为 PushGlobal 的全局变量会影响并发结果。每个渲染调用都要新建一个 TemplateContext这一点我踩过坑后面常见问题里细说。4. 核心执行链路与性能细节4.1 协议中心的主流程实现整个协议中心的入口其实非常薄。收到调用方的请求之后先按协议名找到插件再把上下文组装好交给插件执行。核心执行流程我给出一个简化版的插件实现它完成了最基础的模板渲染加 HTTP 调用public sealed class GenericHttpProtocolPlugin : IProtocolPlugin { public string ProtocolName http-generic; public async TaskProtocolResult ExecuteAsync( ProtocolContext context, CancellationToken ct) { var definition JsonSerializer.DeserializeProtocolDefinition( context.TemplateSource, JsonOptions); var url ScribanRenderer.Render(definition.Request.Url, context.Parameters); var headers new Dictionarystring, string(); foreach (var (key, template) in definition.Request.Headers) { headers[key] ScribanRenderer.Render(template, context.Parameters); } var body definition.Request.Body is null ? null : ScribanRenderer.Render(definition.Request.Body, context.Parameters); using var request new HttpRequestMessage( new HttpMethod(definition.Request.Method), url); foreach (var (key, value) in headers) { request.Headers.TryAddWithoutValidation(key, value); } if (body is not null) { request.Content new StringContent(body, Encoding.UTF8, definition.Request.MediaType ?? application/json); } using var response await context.Client.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, ct); var responseBody await response.Content.ReadAsStringAsync(ct); var extracted ExtractResponseData(definition.Response, responseBody); return new ProtocolResult { StatusCode (int)response.StatusCode, Success response.IsSuccessStatusCode IsBusinessSuccess(extracted), Data extracted, RawResponse responseBody }; } }核心流程不复杂复杂的是对细节的控制。比如HttpCompletionOption.ResponseHeadersRead是必须的尤其是上游返回大报文时如果默认读完整内容客户端会把整个响应体都缓冲到内存流量一大内存就爆。用 ResponseHeadersRead我们可以自己控制要不要读完整个 body在异常情况下甚至可以只读一小段错误信息就结束连接。4.2 HTTP 连接复用的细节处理协议中心承接的是高并发请求连接复用是性能的基本盘。这里说三个细节。第一HttpClient 实例必须复用。我见过有人每次调用都 new HttpClient这在长连接场景下会把端口耗尽。协议中心统一通过 IHttpClientFactory 创建 NamedClient每个上游服务一个客户端实例既隔离了配置又共享了连接池。第二连接生存期要设置。长连接不是越长越好如果上游 DNS 记录变了旧连接继续复用可能打到旧 IP。我的经验是设置PooledConnectionLifetime为 5 到 10 分钟让连接池定期清理旧连接。这个参数在 Linux 容器环境里尤其重要DNS 变更后没有这个设置的服务只能重启容器才能恢复。第三超时时间要分两层。连接超时是建立 TCP 连接的时间一般给 3 到 5 秒总请求超时包含排队、连接、发送、响应全过程根据上游 SLA 给 10 到 30 秒。这两层超时配置不能混用否则一个慢接口会把整个连接池占满。4.3 缓存与并发控制的取舍协议中心的缓存分两层。第一层是上面说的模板缓存这个必须做缓存收益极高。第二层是响应缓存我建议谨慎使用因为 HTTP 协议中心的调用方往往需要实时数据响应一旦缓存语义就变了。并发控制方面我用到的是信号量而不是无限制并发。我们给每个上游客户端配置一个 SemaphoreSlim限制同一上游的并发请求数。这样做是因为上游服务经常有自己的 QPS 上限如果协议中心把并发全部打过去上游会先被打挂然后一堆 5xx 返回。限流是保护上游同时保护自己。public sealed class UpstreamConcurrencyLimiter { private readonly ConcurrentDictionarystring, SemaphoreSlim _gates new(); public async TaskIDisposable AcquireAsync(string upstream, int maxConcurrency, CancellationToken ct) { var gate _gates.GetOrAdd(upstream, _ new SemaphoreSlim(maxConcurrency)); await gate.WaitAsync(ct); return new Releaser(gate); } }这个信号量的值不能太长否则一个响应慢的下游会连带堵住其他协议。通常我会配合总超时一起用超时一到立刻释放信号量避免线程空等。5. 常见问题与排查技巧实录5.1 模板渲染的坑变量不存在、类型不匹配、共享 Context模板渲染的问题排在第一位的是“不报错但结果不对”。因为 StrictVariables 默认是 false模板里{{nem}}拼错不会报错只会渲染为空字符串。结果就是请求发出去了上游返回参数缺失排查半天才发现是变量名打错。我的办法是在开发环境的渲染代码里强行开启严格模式让所有变量错误直接暴露。第二个坑是类型不匹配。比如参数 page 传的是字符串 1模板里想把它当数字加一写{{page 1}}结果是字符串拼接成 11。Scriban 有类型转换规则但如果上游返回类型经常不一致最好在注入参数时就统一成模板友好类型。第三个坑就是前面提到的共享 TemplateContext。如果同一个 TemplateContext 实例被并发请求同时 PushGlobal 再渲染两个请求的参数会互相覆盖。绝对不能全局复用 TemplateContext它只属于一次渲染。5.2 插件加载与依赖冲突插件化系统最常见的问题是“类型不能转换”。调试信息通常像是“无法将类型 X 转换为类型 X”其实根本原因是程序集加载了两份。比如插件目录里复制了一份宿主引用的 Newtonsoft.Json版本还不一样结果插件的 string 参数传到宿主接口时直接被判定为类型不一致。解决思路是明确公共依赖边界。宿主和插件共享的框架程序集不放进插件目录插件目录只放它私有的依赖。如果确实需要覆盖宿主里的某个组件版本就单独放到插件目录并通过 AssemblyDependencyResolver 解析到。这条规则要在团队内部形成规范否则插件多了之后依赖会烂成一锅粥。5.3 上游 HTTP 状态码异常排查速查表协议中心接的接口多了以后各种上游状态码都会遇到。我整理了一个排查速查表协议中心每天的告警里 90% 都能对上号。状态码典型场景排查方向400 Bad Request模板渲染出来的报文不符合上游要求抓报文对比上游文档看字段名、类型、编码401 Unauthorized鉴权信息缺失或过期检查签名时间戳、Token 刷新逻辑403 Forbidden已认证但无权限检查插件声明的权限范围、上游的白名单配置502 Bad Gateway上游网关/代理异常检查上游负载是否被打满连接池是否耗尽504 Gateway Timeout上游处理超时分阶段看是连接超时还是响应超时调超时参数524 A Timeout Occurred上游已接收请求但未及时返回多出现在代理后面的慢接口需要改推异步任务这里特别提醒 400 和 403 的混淆。403 往往是权限问题模板渲染再对也没用400 则大概率是模板渲染的报文和上游预期不一致。排查 400 时第一件事是打开插件日志把真正发给上游的 payload 完整打出来而不是只看错误码否则只能靠猜。5.4 一个真实案例对接到带状态字段回传要求的上游有一次接一个大模型通道的兼容接口上游要求流式返回里的reasoning_content字段在下一轮请求中必须原样传回去否则直接返回 400。第一次遇到时我们的协议模板根本没定义这个字段请求报错后一直以为是签名问题排查了很久才发现是少了这个透传字段。这个案例的教训是协议中心一定要保留“原始响应结构”的上下文。在实际业务里很多上游会在连续对话类接口里要求带回上一轮的某个标记这些字段往往不在业务参数里而是来自上一次响应。解决办法是协议模板里允许声明一组passthrough字段上一次响应里的值会被提取出来注入到下一轮请求的参数池中。模板只要写{{passthrough.reasoning_content}}就能原样带回去。这种小功能用纯手写适配代码也不是不行但每接一个服务都要重新写一遍很烦。放在协议中心里就是一个配置项接入新服务五分钟搞定。6. 一些后话这套设计用下来的真实感受这套方案在我这边落地跑了半年左右最大的感受是“新接口接入成本确实被压下来了”。以前接一个带签名的新服务开发加联调怎么也得一到两天现在多数情况是运维或后端同事在管理后台配置一份模板再写一个几行的签名插件半天就能走通。因为模板是文本、插件是独立目录出问题还可以快速回滚不像以前改代码发版那么重。如果让我重新做一次会在两个地方提前下功夫。一是插件管理界面至少要有基本的启用、停用和版本回退能力纯靠运维手动拷贝目录虽然也能用但不直观。二是模板测试工具在线输入参数、渲染出结果、对比上游期望报文这段流程如果能可视化接入体验会再上一个台阶。另外想提醒一句模板引擎虽好但别被它惯坏了。有些同事习惯把复杂逻辑写进模板里比如在模板里做数据聚合、映射状态码这其实又把逻辑散落回了配置里。我的底线是模板里只做形状变换转换逻辑一律走插件代码。这个边界守住了协议中心才能真正成为所有人都可控的基础设施而不是又一个只有原作者敢碰的黑盒。最后如果你也要做类似的东西建议从最简单的“一个插件 一份模板”开始跑通链路后再逐步加特性别一上来就把插件管理体系、热更新、响应缓存全做上那样只会把自己拖进泥潭。