跳到主要内容
版本:3.1

第三方集成(Aegis.Integration)

医疗行业做产品绕不开一件事:每家医院的第三方接口都不一样,同一家厂商在不同医院还各有变种。Aegis.Integration 把这件事拆成两半:框架只决定"由哪个适配器实现"(绑定与路由),不规定"出入参怎么转换"(字段映射、协议选择全在适配器里)。业务侧定义稳定的集成边界(Connector),实现方按对接目标交付适配器(Adapter)——对接目标通常是某家厂商,也可以是某家医院的定制服务、同一厂商的某个版本分支。换实现只改一条绑定配置。

组件概览​

字段说明
组件名称第三方集成
真实类库Aegis.Integration
组件定位Connector 声明业务集成边界,Resolver 按绑定路由到具体适配器;地址与传输归 Broker,本组件只管"谁来实现"
引入方式安装 NuGet,Component.deps.json 的 Load 清单声明 Integration
核心能力Connector/Adapter 注册表、策略源链路由、解析回退链、命名纪律守卫、编译期分析器(AEGINT001-003)
主要配置根级 Integration(Bindings 绑定表)
是否可扩展是(IIntegrationStrategySource 策略源 SPI)
组件声明Integration(Load 清单声明,两阶段共用)
典型配套远程调用客户端(Aegis.Net.Broker)、自定义组件注入

什么时候要用它​

适合场景:

  • 同一业务能力要对接多家厂商(每家医院的 LIS、EMR、PACS 各不相同)
  • 接口定义与适配器实现需要分属两侧——不论这两侧是不同团队还是同一个人的两个阶段,边界都是收益来源
  • 希望换实现(换厂商、换版本、切医院定制)只改配置、不发版

不适合场景:

  • 只有一家固定对接方,直接用 BrokerClient.Get<T>() 就够,多一层装配没有收益(见远程调用客户端)
  • 需要熔断、重试、超时治理。这些归 Broker 的过滤器体系,本组件不掺和

如何使用​

以下示例与 demo/BaseDemo/Aegis.Webapi.BaseDemo/Integration/ 的结构一致(双厂商 LIS:东软、金域),可以直接对照源码。

第一步:安装组件​

dotnet add package Aegis.Integration

适配器里调用厂商 API 用到 BrokerClient,项目还需引用 Aegis.Net.Broker。

第二步:定义 Connector(业务侧)​

接口名就是业务能力,必须以 Connector 结尾;出入参用统一模型,厂商字段概念不出现:

using Aegis.Integration;

[Connector("Lis")]
public interface ILisOrderConnector : IConnector
{
/// <summary>推送检验医嘱到 LIS,返回统一形状的受理回执。</summary>
Task<LabOrderAck> PushAsync(LabOrderPushRequest request);
}

public sealed record LabOrderPushRequest(
string OrderNo, string PatientCardNo, string DeptCode,
string ItemName, DateTime OrderedAt);

public sealed record LabOrderAck(bool Accepted, string VendorAckCode, string MappedDeptName);

"Lis" 是路由键,与接口名解耦:接口改名不会断掉绑定链。同一个键下可以注册多个厂商适配器。

第三步:实现适配器(实现侧,以厂商为例)​

每个厂商一个包/目录,含四样东西:Broker Contract(厂商接口形状)、厂商 DTO(出入参形状)、Transformer(双向转换)、Adapter(组装)。以东软为例:

// Broker Contract:厂商 HTTP 端点的真实形状,蛇形命名止步于此
public interface IDongsoftLisContract
{
[Post("order")]
Task<string> PushOrder([Body] DongsoftHl7Order order);
}

// 厂商 DTO:对接目标的出入参形状,与统一模型刻意不同(正是要转换的凭证)
public sealed record DongsoftHl7Order(
string hl7OrderNo, string cardNo, string dept, string item, string orderTime);

// Transformer:统一模型 ↔ 厂商形状的双向转换。纯代码、无依赖,直接 new
public sealed class LisOrderTransformer
{
// 值域对照示例用查表数据(生产建议 DB/配置,改码表不应触发发版)
private static readonly Dictionary<string, string> DeptMap = new()
{
["1001"] = "DS-内科", ["1002"] = "DS-外科"
};

public DongsoftHl7Order ToVendor(LabOrderPushRequest r)
=> new(r.OrderNo, r.PatientCardNo, DeptMap.GetValueOrDefault(r.DeptCode, r.DeptCode),
r.ItemName, r.OrderedAt.ToString("yyyyMMddHHmmss"));

public LabOrderAck FromVendor(string vendorAck)
{
var parts = vendorAck.Split('|');
return new LabOrderAck(parts.Length > 1 && parts[1] == "OK", vendorAck,
parts.Length > 3 ? parts[3] : string.Empty);
}
}

// Adapter:[Adapter] 声明厂商身份,实现 Connector 接口。
// 地址是适配器自己的事:从自己的配置读出 BaseUrl 传给 BrokerClient.Get——Integration 不管理地址
[Adapter(Provider = "DongsoftLis")]
public class DongsoftLisOrderAdapter : AdapterBase, ILisOrderConnector
{
private readonly LisOrderTransformer _transformer = new();
private readonly string _baseUrl;

public DongsoftLisOrderAdapter(ILogger<DongsoftLisOrderAdapter> logger, IConfiguration configuration)
: base(logger)
{
// 厂商地址从应用配置读取(appsettings.json 根级 Lis 节),取值形如:
// http://192.168.1.2/DongsoftLis/
_baseUrl = configuration["Lis:Dongsoft:BaseUrl"]!;
}

public async Task<LabOrderAck> PushAsync(LabOrderPushRequest request)
{
var contract = BrokerClient.Get<IDongsoftLisContract>(_baseUrl);
Logger.LogInformation("东软 LIS 推送:{OrderNo}", request.OrderNo);

var vendorAck = await contract.PushOrder(_transformer.ToVendor(request));
return _transformer.FromVendor(vendorAck);
}
}

金域适配器(KingmedLisOrderAdapter)同构:另一个 Contract(JSON 接口、条码制)、另一个 Transformer、[Adapter(Provider = "KingmedLis")]。两个适配器注册在同一个 "Lis" 键下,运行期由绑定决定用哪个。

为什么单独建 Transformer 类。 Transformer 不是必需的——两三个字段的直接赋值写在适配器方法里也能跑。但一旦转换涉及字段对照、格式处理(日期、编码)、条件分支,就值得拆出来,收益有三条:

  1. 可以脱离 HTTP 单测。 转换是对接中最容易出错的部分。独立成类后它是纯函数:统一模型进、厂商形状出,测试里直接 new 出来断言输入输出即可,不用启动容器、不用 mock 传输;写在适配器里,测转换就得连传输一起测。
  2. 改动隔离。 现场最常动的就是字段对照(医院新增科室、厂商调整回执格式)。独立类让这类变更的 diff 只落在 Transformer 一个文件,评审聚焦、回归范围明确。
  3. 适配器只剩组装。 拿契约客户端、调转换、记日志、返回——所有适配器结构雷同,读代码的人一眼定位到"这家厂商的差异在转换"。

保持 Transformer 无依赖(不注入配置和日志),需要值域对照就用查表数据。

第四步:准备配置​

{
"Integration": {
"Bindings": { "Lis": "DongsoftLis" }
}
}

(示例取自 BaseDemo,跑通闭环用;厂商地址是适配器自己的配置,取法见第三步适配器的 configuration["Lis:Dongsoft:BaseUrl"]。)

第五步:声明组件(写进 Load 清单)​

{
"Components": {
"Load": ["Integration"]
}
}

Load 一份清单两阶段共用:服务阶段装配全套服务(注册表、策略源、解析器、DI 转发);中间件阶段做启动期绑定预检——多适配器又没有绑定配置时,横幅提前给出 Warn(此时业务自定义策略源已注册完毕,检查是完整的)。只让服务阶段生效、跳过预检,得用旧式的单阶段覆盖写法(只写 Services 不写 Load),问题会推迟到首次解析才报错。

第六步:业务代码直接注入​

public class EmrOrderService
{
private readonly ILisOrderConnector _lis;

public EmrOrderService(ILisOrderConnector lis) => _lis = lis;

public async Task<string> PushOrderToLisAsync(string orderNo, string cardNo, string deptCode)
{
var ack = await _lis.PushAsync(new LabOrderPushRequest(orderNo, cardNo, deptCode, "血常规", DateTime.Now));
return ack.Accepted ? $"已受理(厂商回执 {ack.VendorAckCode})" : $"未受理(厂商回执 {ack.VendorAckCode})";
}
}

EmrOrderService 不知道当前是东软还是金域,也不需要知道。框架对 Connector 接口做了 DI 转发:注入接口,实际拿到的是当前绑定指向的适配器实例。

接入后怎么确认生效​

  • 启动横幅出现 Integration: 注册 N 个 Connector / M 个 Adapter
  • 同一个 Connector 注册了多个适配器而没有绑定时,横幅有对应 Warn(中间件阶段的预检产出,Load 清单里的 Integration 会正常走到这一步)
  • 首次解析出现 Integration 解析:Connector 'Lis' → Provider 'DongsoftLis' 日志(同组合只记一次)
  • GET route-info 能看到注册表与当前绑定
  • 注入 ILisOrderConnector 调用一次,返回统一形状的回执

如何绑定​

绑定回答一个问题:某个 Connector 由哪个 Provider 实现。绑定写在配置里:

"Integration": {
"Bindings": { "Lis": "DongsoftLis" }
}

绑定关系什么时候建立​

分两层,答案不一样:

注册表在启动时建立,之后只读。 组件注册阶段扫描一次程序集,得到"有哪些 Connector、每个 Connector 有哪些适配器"的完整清单。适配器类的增减以发版为界——程序集不变,清单不变。

绑定在启动时确定(内置 Config 源),决策机制是每次解析现场询问。 每次解析时,解析器都会重新询问策略源"这个 Connector 用哪个 Provider",解析器自己不缓存答案。当前内置的 Config 源读的是启动时的配置快照,所以配置绑定实际在启动那一刻就固定了:改 Bindings 后要重启才生效。适配器实例则是每次解析新建(Transient)——绑定决定"用哪家",实例按需创建、用完即弃。

为什么定在启动时,而不是运行中可换:

  1. 换实现是计划事件。 换厂商、切医院定制、升版本,都有通知、有窗口、有回退预案,重启在这个语境下不是代价。
  2. 运行中静默换绑是危险语义。 请求处理到一半换了厂商,同一笔业务前后两段打到两家,一致性无从谈起。
  3. 绑定确定得越早,问题暴露得越早。 绑错 Provider,启动预检(Warn)或首次解析(明确报错)就会拦住,而不是业务跑了半天才发生漂移。

解析器不缓存绑定决策,这是有意设计,目的是动态解析:源的数据在运行时被替换(数据库改表、灰度开关切换),下一次解析就按新数据路由。代价是源要承受每次解析的询问——自建带 I/O 的策略源必须加缓存(见自定义扩展)。绑定的新鲜度由策略源自己决定:将来接入数据库策略源(自带短缓存,见自定义扩展),改绑定表的生效窗口就是缓存过期时间,不用重启。"数据什么时候新鲜"交给扩展点回答,框架不替你决定。

策略源链与解析回退​

策略源回答一个问题:某 Connector 用哪个 Provider。解析时按 Order 降序依次询问,先命中先用:

代码级策略源(业务自定义,高 Order,如 100)
│ 无意见
▼
Db 策略源(预留,约定 Order=10,本期未实现)
│ 无意见
▼
Config 策略源(ConfigIntegrationStrategySource,Order=0,读 Integration:Bindings)

命中绑定后,注册表里找不到对应 Provider 会在解析期直接报错,错误消息列出该 Connector 已注册的全部 Provider,不会静默降级。

没有命中任何绑定时走回退链:

  1. 唯一注册自动选中:某 Connector 只有一个适配器,不用配置,直接用它(接口先行、补一个实现的过渡期很好用)
  2. 多适配器无绑定,拒绝静默路由:启动期预检给 Warn(预检在中间件阶段执行,Load 写法天然满足),真正解析时报 AegisConfigurationException,提示补绑定或裁剪适配器

解析器对每个 (Connector, Provider) 组合只在首次命中时记一条日志(含来源:哪个策略源、还是 unique-adapter 回退),后续解析不再刷屏。

这一章讲的是链的行为;自己写一个策略源(数据库绑定、程序内计算)的契约与完整示例见自定义扩展。

换实现与本地假实现​

换实现 = 改绑定。Bindings 里的 Lis 从 DongsoftLis 改成 KingmedLis,重启即生效,业务代码一行不动。这是这套模型的核心收益:厂商差异被封在适配器包里,切换动作只是配置。

本地开发和测试用假实现,走同一条路:把假实现写成一个适配器,开发环境用绑定指向它——

[Adapter(Provider = "LocalStub")]
public class LocalLisStub : AdapterBase, ILisOrderConnector
{
public LocalLisStub(IOptions<IntegrationOptions> options, ILogger<LocalLisStub> logger)
: base(options, logger) { }

public Task<LabOrderAck> PushAsync(LabOrderPushRequest request)
=> Task.FromResult(new LabOrderAck(true, "local-stub", ""));
}
// appsettings.Development.json
"Integration": { "Bindings": { "Lis": "LocalStub" } }

假实现和真实适配器在模型里地位完全平等,无需了解任何 DI 注册细节。

弹性与故障处理​

Integration 只管"谁来实现",不管"对方挂了怎么办"——出站调用的超时、重试、兜底全部由 Broker 提供,但要适配器的作者自己声明,这里不会默认补上。这是第三方对接最容易漏的一层:转换写完、调用通了就收工,没人想过对方服务重启的那三十秒会发生什么。

漏掉的后果是具体的:

  • 没有超时——一个挂住的第三方请求拖住调用线程,高峰期连锁耗尽连接
  • 没有重试——网络抖一次、对方发版重启一次,业务单据就失败一批
  • 没有兜底——异常原样穿透适配器打到业务层,业务代码被迫处理本不属于它的故障

Broker 提供三种声明位置,按需选。

契约级(最常用):标注即声明,适合幂等端点(查询类天然幂等):

public interface IDongsoftLisContract
{
[Get("report/{barcode}")]
[Timeout(3000)]
[Retry(2, 100, 1000)]
Task<LabReport> GetReport([Path] string barcode);
}

调用点(Fluent,最细):契约方法声明返回 IBrokerTask<T>,在适配器调用处链式声明——兜底值只有这里拿得到:

public async Task<LabOrderAck> PushAsync(LabOrderPushRequest request)
{
var contract = BrokerClient.Get<IDongsoftLisContract>(_baseUrl);
return await contract.PushOrder(_transformer.ToVendor(request))
.WithTimeout(TimeSpan.FromSeconds(5)) // 每次尝试独立限时
.Retry(2)
.WhenCatch<BrokerTimeoutException>() // 超时视为瞬时故障
.WhenResult(r => r.Contains("BUSY")) // HTTP 200 但厂商回执忙
.Fallback(new LabOrderAck(false, "pending", "")); // 兜底:未受理,待对账
}

实例级:BrokerClient.RetryPolicy.ShouldRetryOnResult,按业务码重试的全局策略。

选择口径:

  • 查询类:契约上 [Timeout] + [Retry] 就够
  • 推送类(医嘱、缴费):重试要谨慎——厂商回执带去重号才配 Retry / WhenResult,否则用 Fallback 返回"未受理"回执,由异步对账兜住
  • 兜底值落在适配器里最合适:返回统一形状的合法回执(如 Accepted = false + 待确认),业务层拿到的永远是可处理的数据,对方不可用不会以异常形态击穿业务

Fallback 细则:统一模型的设计前提。 Connector 的统一模型要把"第三方失败"建模成数据而不是异常——回执里预留降级态字段(Accepted = false、VendorAckCode = "pending"),Fallback 把一切失败形态(超时、重试耗尽、不可重试异常)收敛成这种合法返回值,业务代码因此永远不需要 try/catch Connector 调用。设计 Connector 返回类型时先问一句:第三方完全不可用时,这个方法该返回什么? 答案就是 Fallback 值;答不出来,说明统一模型缺一个状态字段,先补模型再写适配器。

两个形态:

// 固定值:语义简单的回执
.Fallback(new LabOrderAck(false, "pending", ""))

// 工厂:需要上下文时用(记日志、查缓存取上次成功值)
.Fallback(() =>
{
Logger.LogWarning("东软 LIS 不可用,医嘱转待对账");
return new LabOrderAck(false, "pending-reconcile", "");
})

边界语义三条:

  1. 兜底返回不等于业务成功。业务靠回执字段区分(Accepted = false 走挂起/对账流程),别把降级态伪装成成功——伪装了,对账就找不到该捞的单。
  2. 调用方主动取消永不吞。Fallback 不拦截 CancellationToken 的取消,原生传播——"用户不等了"和"第三方挂了"是两回事。
  3. 兜底值属于适配器知识。东软挂了返回什么、金域挂了返回什么可以不同(有的厂商有离线缓冲语义,有的没有),但都必须符合统一模型——这正是"兜底值只有调用方知道"的含义。

特性优先级、每尝试独立超时、HttpContent / Stream 请求体不可重放自动跳过重试等语义细节,见远程调用客户端(Aegis.Net.Broker)。

工程实现:Connector 包、厂商包与医院交付包​

示例把 Connector、适配器都放在 BaseDemo 一个项目里,真实工程要拆开。这一节回答三个高频问题:接口定义放哪、多家厂商各管一段怎么摆、按医院建包行不行。

Connector 包:业务与厂商包的共同引用点​

厂商实现包需要实现 ILisOrderConnector,但不能引用业务包(业务包带着实体、服务一大家子,拖给厂商包等于全量耦合)。解法是独立的 Connector 包,只放 Connector 接口和统一模型,包名用 Connectors 后缀(如 Hospital.Lis.Connectors):

Hospital.Lis.Connectors/            ← Connector 包(业务侧所有):ILisOrderConnector + 统一模型 record
Hospital.Emr/ ← 业务包:引用 Connectors,注入插座
DongsoftLis.Integration/ ← 厂商包:引用 Connectors + Aegis.Integration,实现适配器
KingmedLis.Integration/

依赖方向是业务 → Connectors ← 厂商包。业务包和厂商包互不可见,共享的只有那个几 KB 的 Connector 包。Connector 包是业务侧的资产(能力定义属于业务),实现方只是消费方。

Connector 包三条纪律,决定它能不能一直薄下去:

  1. 只装三样东西:[Connector] 接口、统一模型 record、对 Aegis.Integration 的引用。别的什么都不进。
  2. 统一模型必须自含。写 Connector 接口时想引用业务实体或枚举,说明统一模型还没提炼干净,这是模型的问题,不是布局的问题。同理,想照抄厂商字段命名,说明抽象方向反了——Connector 从业务正向抽象(见核心概念),厂商文档不是它的设计输入。
  3. 加法式演进加 SemVer。Connector 包是全生态的稳定点,改一个成员签名等于所有厂商包重编。

拆分粒度按集成领域归组(LIS 一个、HIS 患者一个、医保一个),不要按接口拆。医院产品的集成领域是个位数,不会包爆炸。起步可以整个产品一个Connector 包,出现第一个"某厂商包被迫引用无关领域 Connector"的信号时再按领域拆。

多厂商分段负责:接口按可替换单元切​

同一家医院里 LIS 的不同环节可能来自不同厂商(医嘱推送走东软,报告回传走金域)。能不能落地取决于接口切分,因为绑定按 Connector 路由键独立选择,接口切分粒度就是厂商替换粒度:

// Connector 包内,按可独立替换的能力单元切接口,路由键带领域层级
[Connector("Lis.Order")] public interface ILisOrderConnector : IConnector { ... }
[Connector("Lis.Report")] public interface ILisReportConnector : IConnector { ... }
[Connector("Lis.Specimen")] public interface ILisSpecimenConnector : IConnector { ... }

厂商包只实现自己负责的接口,Provider 值跨接口保持一致(厂商身份):

"Integration": {
"Bindings": {
"Lis.Order": "DongsoftLis",
"Lis.Report": "KingmedLis",
"Lis.Specimen": "DongsoftLis"
}
}

注册表按 (ConnectorKey, Provider) 登记,厂商包实现部分接口完全合法;只有一家实现的子能力连绑定都不用写(unique-adapter 自动选中)。切分判断只问一句:这两个能力会不会被医院独立选型或独立替换?会就分开,总是一起卖就合成一个接口多方法。切粗了医院换不动局部,切细了契约爆炸绑定琐碎。

配套纪律:不按厂商命名 Connector(IDongsoftLisOrderConnector 是防腐失败);共享模型(患者、标本条码)放 Connector 包公共区,不要每个子能力复制一份;子能力接口独立演进,改报告接口只重编实现报告的厂商包。

医院交付包:薄组装,不是实现容器​

一家医院一个交付物是合理边界,但先分辨包里装的是哪类差异:

差异类型例子归宿
厂商差异东软 SOAP 竖线串、金域 JSON 条码厂商包,跨医院复用
医院差异对接地址、凭据、码表、绑定配置和数据,不属于任何代码包
医院私有流程信息科自加的私有接口、特殊环节医院定制包

推荐形态是医院包当薄组装层:引用需要的厂商包,收容极少数真正的私有适配器,医院差异主体放它自带的配置。厂商实现仍是一份,修正一个东软接口缺陷不用打 N 个医院包的版本。

HospitalX.Integration/            ← 医院交付包:薄
├── 引用 DongsoftLis.Integration
├── 引用 KingmedLis.Integration
├── HospitalXPrivateAdapter.cs ← 仅当存在私有流程,理想情况不存在
└── appsettings(厂商地址、码表、Integration 绑定)

让医院包变厚(把厂商实现直接写进去)的正当条件只有一个:同厂商接口在医院间事实漂移(同是"东软 LIS",A 院和 B 院部署的定制版本接口就是不一样),厂商包复用的前提不成立。判断信号:接入第三家同厂商医院时,如果在厂商包里开始堆 if-hospital 分支,就按医院拆;如果每次只是改配置,厂商包继续复用。变厚之前要清楚这是拿复用性换交付速度的明确取舍,50 家医院 50 份适配器拷贝、修正传播打 50 个包,是产品化倒退的典型路径。

医院包有个真实红利:厂商割接期两套实现同包共存,改一行绑定即切换,演练和回退都轻。

双厂商并存:组合适配器​

同一子能力要双写或主备(检验科两套系统并行),绑定层帮不上:绑定是每键单选。写一个组合适配器,业务侧依旧无感:

[Adapter(Provider = "DualWrite")]
public class DualWriteLisOrderAdapter : AdapterBase, ILisOrderConnector
{
// 注入两个厂商通道,双发或择优,失败策略自定
}

路由选"谁来实现"和编排"怎么组合实现"是两层,后者不进绑定语义。

配置项说明(IntegrationOptions)​

配置节 Integration(根级,不挂 Aegis 节——绑定是业务路由数据,和厂商地址配置同类):

配置项类型默认值说明
Bindings字典空绑定表:connectorKey → provider,如 Lis → DongsoftLis

本组件只管绑定,第三方地址不在配置节职责内——适配器自行获取(自己的配置节、环境变量均可)并传给 BrokerClient.Get<T>(baseUrl)。

SOAP 契约必须传非空地址

适配器传给 BrokerClient.Get<T> 的 baseUrl,对接 WebService(SOAP 契约,带 [ServiceContract])时必须非空——null 会直接抛 ArgumentNullException;REST 契约为空时回退契约 [BasePath]。

自定义扩展​

策略源怎么写​

IIntegrationStrategySource 回答一个问题:这个 Connector 用哪个 Provider。返回 Provider 名;没意见返回 null,链去问下一个源。

三条规则:

  1. 只查不改。每次解析都会调这个方法,别在里面做状态变更;带 I/O 的源要加缓存,用缓存装饰器包一行就行。
  2. 抛异常前想清楚。异常会一路传到业务调用点。抛出去,还是 catch 后返回 null(链落到 Config),由源自己决定。
  3. 返回的 Provider 必须真实存在。指到没注册的 Provider,解析器直接报错,错误里带已注册清单,不会静默路由。

Order 决定谁先被问:大的先问,命中即停,同 Order 按注册顺序。

Order层用途
100+代码/计算源灰度、开关、显式覆盖
10数据库源运维改表调绑定
0Config 源(内置)随应用发布的基线

缓存装饰器:CachedIntegrationStrategySource​

带 I/O 的源需要缓存,但缓存不好写:没绑定的键会反复空查、过期那一瞬间并发请求会一起打到库上、系统时间跳变会让缓存行为不可预期。装饰器把这些一次做对:

services.AddSingleton<IIntegrationStrategySource>(
new CachedIntegrationStrategySource(dbSource, TimeSpan.FromSeconds(30)));
  • 答案缓存 TTL 秒,查不到(null)也缓存,不会反复空查
  • 过期后并发请求只回源一次
  • Order 跟着内层源走,链上位置不变
  • 内层抛异常不缓存也不吞——故障时怎么办(抛还是降级)由内层源自己定

TTL 就是改绑定的生效窗口:数据变了,最迟 TTL 秒后生效。

写一个数据库绑定源​

绑定表一张、两列、主键即可:

CREATE TABLE aegis_integration_binding (
connector_key VARCHAR(64) NOT NULL,
provider VARCHAR(64) NOT NULL,
PRIMARY KEY (connector_key)
);

最小实现就这么大——查表、返回、注册:

public sealed class DbIntegrationStrategySource : IIntegrationStrategySource
{
public int Order => 10; // 约定档位:代码源之下、Config 之上

private readonly IFreeSql _freeSql;
public DbIntegrationStrategySource(IFreeSql freeSql) => _freeSql = freeSql;

public string? Resolve(string connectorKey)
=> _freeSql.Select<IntegrationBindingRow>()
.Where(b => b.ConnectorKey == connectorKey)
.First()?.Provider; // 查无此行返回 null = 无意见,链去问 Config
}

services.AddSingleton<IIntegrationStrategySource, DbIntegrationStrategySource>();

这个版本直接可用,上生产前补两件事——都因为同一个原因:解析器每次解析都会调这个方法(框架不缓存绑定),高频是常态。

补充一:缓存。 不加缓存等于每次业务调用查一次库。别手写,用上文的缓存装饰器包上:

services.AddSingleton<IIntegrationStrategySource>(
new CachedIntegrationStrategySource(
new DbIntegrationStrategySource(freeSql), TimeSpan.FromSeconds(30)));

TTL 就是改绑定的生效窗口:改表后最迟 30 秒、下次解析即切换,不用重启。按运维需要的生效速度调这个值。

补充二:故障时的选择。 Db 不可用时只有两个选项,按你的语义挑一个:

  • 降级:catch 后返回 null——链落到 Config 基线绑定,第三方调用不受 Db 故障牵连。适合"Db 是运维调整层、Config 是部署基线"的定位。记得记一条 Warn(加个 bool 标记避免每秒刷屏)。
  • 上抛:直接 throw——"Db 是唯一权威,拿不到绑定就该失败"。契约允许异常原样上抛,解析器不吞。

两个选项没有对错,只有语义匹配;医疗现场通常选降级,因为第三方本身是好的,不该被绑定库拖死。

另外两条语义是框架送的,不用你写:多适配器无绑定的启动预检会问到 Db 源(Middleware 阶段所有源已注册);表里写了未注册的 Provider,第一次解析就报错(错误信息含已注册候选),不会静默路由。

写一个程序内计算源​

适合不需要数据落库的决策:灰度切流、按开关、(将来)按租户上下文。可变形态长这样——开关在内存里,运维随时切换,下次解析即生效(框架不缓存绑定决策,这正是动态解析能力的直接体现):

public class GraySwitchStrategySource : IIntegrationStrategySource
{
public int Order => 100; // 代码级:压过 Db 和 Config

private volatile string? _lisProvider;

// 只读:解析器每次解析调用
public string? Resolve(string connectorKey)
=> connectorKey == "Lis" ? _lisProvider : null; // 未设置 = 无意见,交给 Db/Config 层

// 变更入口:管理端点、配置中心回调调用,不经过 Resolve
public void Switch(string? provider) => _lisProvider = provider;
}

// 注册为单例,并把开关实例交给你的管理端点
services.AddSingleton<GraySwitchStrategySource>();
services.AddSingleton<IIntegrationStrategySource>(sp => sp.GetRequiredService<GraySwitchStrategySource>());

两条纪律:

  1. Resolve 只读,变更走外部入口。反模式是在 Resolve 里顺手改状态(计数、自动切换)——解析器高频调用会让状态不可预期地漂移;状态由管理端点或配置中心显式设置则完全成立。需要"切换后固化",把状态落到 Db 源的表里。
  2. 这种源不要包缓存装饰器。它的价值就是每次解析读到最新值,包了缓存等于把秒级切换拖到 TTL 之外。要包的是"读一次代价高、变化不频繁"的源(数据库、HTTP 配置中心)。

route-info 诊断端点​

现场排障第一问是"走的哪家"。注册表(IntegrationAdapterRegistry)和当前绑定(IntegrationOptions.Bindings)都是公开可注入的,BaseDemo 的 IntegrationDemoController 提供了参考实现,GET route-info 返回:

{
"connectors": [{ "key": "Lis", "connector": "ILisOrderConnector", "providers": ["DongsoftLis", "KingmedLis"] }],
"bindings": [{ "connectorKey": "Lis", "provider": "DongsoftLis" }]
}

核心概念:词根分工​

整套模型由六个词根组成,每个词根只管一件事:

词根类型 / 载体归属职责
Integration本组件框架装配层总称
ConnectorIConnector 派生接口 + [Connector("Lis")]业务侧从业务能力正向抽象出的集成边界,接口名 = 业务能力
ResolverIIntegrationResolver框架按策略源链选适配器,把接口解析成实例
Adapter[Adapter(Provider)] 类 + AdapterBase实现侧实现转接(Provider 标识实现方),出入参形状与字段映射的转换在这里发生
Transformer业务自定类实现侧字段/结构转换,形态自选
Broker ContractAegis.Net.Broker 契约接口实现侧对接目标 API 的传输描述

"业务侧 / 实现侧"说的是对问题的视角,不是组织角色:两半可以是两个团队,也可以是同一个人的两个阶段——边界是前提,分工不是。

两个比喻贯穿始终:

  • Connector 是插座,Adapter 是转接件。 业务代码构造注入插座接口,拿到的是统一模型;转接件负责把实现方转入插座。插座形状由业务侧定义,转接件按实现方交付。
  • Connector 是抽象层。 Connector 面向统一模型(业务标准形状),Broker Contract 面向对接目标的真实接口(蛇形命名、竖线回执串、条码制都在这一侧)。厂商私有概念止步于 Adapter 和 Transformer,不渗进业务层。防腐层(ACL)的意图就在这里:对接方换掉或改版,业务代码不动。

Connector 是从业务抽象出来的产物,不是从厂商接口反推的。 抽象方向只有一条正路:先看业务要做什么——方法名是业务动作(送检医嘱、查询报告),模型是业务语义(LabOrderPushRequest),然后才让 Adapter 去消化"对接目标实际怎么提供"这个事实。厂商 API 文档不是 Connector 的设计输入。最常见的错误姿势恰恰是拿着厂商接口文档设计 Connector,照抄它的方法形状和字段命名——那等于防腐层从第一天就是漏的,对接方改版直接击穿业务代码。自查方法:接口评审时遮住厂商文档,读接口的人应该读出"这是我们的医疗业务";读出"这是东软的接口",方向就反了。

Provider 是实现方标识,不等于厂商。 绑定表按它选择"这个 Connector 由谁来实现"。按厂商切分(DongsoftLis / KingmedLis)只是最常见的维度,同样成立的还有:

切分维度Provider 取值什么时候用
厂商DongsoftLis、KingmedLis默认形态,本文主线举例
医院HospitalX同一厂商的接口在医院间漂移,各院一个适配器
版本DongsoftLis.V2同厂商新旧版本并存、分批迁移

切分维度 = 你希望按什么粒度切换实现,绑定表换的就是这个维度的值。本文以厂商对接为主线举例(医疗场景最普遍),套用时把"厂商"替换成你的实际维度即可。

判断该不该多一个 Provider,看的是接口本身是否不同:出入参数形状、字段命名、路径结构、协议(SOAP 还是 REST)——一句话,需不需要另写一份 Broker Contract。需要另一份契约,才需要另一个适配器和 Provider 值。地址不算判断依据:生产、演练各连一套地址但契约完全相同时,一个适配器都不用动——各环境自己的配置给不同地址,Broker 自身的调用能力就够,不需要 Integration 介入。

框架对"出入参怎么转换"零规定:映射能手写、能用映射库、能查码表;适配器继承 AdapterBase 只是为了复用日志,不是识别条件。识别条件是 [Adapter] 特性加实现了某个 Connector 接口。

常见问题​

用单阶段覆盖把 Integration 裁出中间件,有什么后果?​

服务装配正常,注入和解析都能用。缺的是启动期绑定预检:多适配器无绑定的问题不会在启动横幅提前暴露,要等到首次解析才报错。直接写 Load 清单,两个阶段天然都在。

启动横幅提示"有 N 个适配器且无任何策略源绑定"​

两个解法任选:在 Bindings 里补一条绑定;或者确认现场只需要一家,把多余的适配器包从引用里拿掉。

注入的 Connector 是什么生命周期?​

转发注册为 Transient,每次解析都会新建适配器实例。适配器构造保持轻量,重资源放到方法内获取。适配器依赖只注入 Singleton/Transient 服务:解析器持根容器,注入 Scoped 依赖会形成 captive dependency(带作用域的解析入口在后续版本提供)。

能往 IConnector 里加公共方法或属性吗?​

不能。它是纯标记接口,全部业务接口都继承它,加任何成员都是破坏性变更。公共能力放各自的 Connector 接口里声明。

适配器不继承 AdapterBase 可以吗?​

可以。识别只看 [Adapter] 特性和 Connector 接口实现。继承 AdapterBase 的收益是现成的 Logger;地址与配置适配器自己决定从哪拿。

AI 辅助开发:定义 Connector 前先问开发者​

本节写给 AI 编码助手:你在业务仓里生成 Connector、适配器代码时,遵守本节。

AI 生成集成代码最容易犯的错,是拿到厂商 API 文档或示例报文后直接照着它生成 IXxxConnector——方法形状、字段命名全是厂商的。Connector 是从业务抽象出来的(见核心概念),照厂商反推会让防腐层从第一天就是漏的,之后所有厂商改动都击穿业务代码。

所以定义 Connector 之前不要写代码,先向开发者问清这些问题,拿到答案再动手:

  1. 这个 Connector 代表什么业务能力? 要一个业务动作的答案(送检医嘱、查询报告);回答是"对接东软的 XXX 接口"就追问业务目的。
  2. 业务真正需要哪些方法? 厂商有但业务不用的不加;业务要但厂商拆成三个调用的,合成一个方法。
  3. 出入参的业务字段是什么? 统一模型用业务语义命名,厂商字段(蛇形命名、条码制)只允许出现在 Broker Contract、DTO、Transformer 一侧。
  4. 预期按什么维度换实现? 厂商、医院还是版本——决定 Provider 值和接口切分粒度。
  5. 第三方完全不可用时,这个方法该返回什么? 降级态字段(如 Accepted = false + 待确认)要在统一模型里预留,这是 Fallback 的设计前提。

开发者回答与厂商文档冲突时,以开发者为准。开发者不在场、答不上来时,停下询问,不要替业务猜抽象——猜出来的统一模型是所有后续适配器的错误地基。

禁止照抄示例。文档与 demo 里的 ILisOrderConnector、LabOrderPushRequest 是 LIS 场景的示例——示例给的是结构(怎么声明、怎么分层),不是内容(接口名、字段、业务语义)。把它们复制进业务仓当自己的 Connector,等于把别家的业务抽象当自己的地基。每个 Connector 的名字、方法、模型必须来自本次询问的答案。

自检:写完 Connector 后遮住厂商文档读一遍,读出"这是某家厂商的接口"就重来。

编译期强制。 上述纪律的可机械检查部分已随包分发 Roslyn 分析器(AEGINT001-003):Connector 后缀、禁用后缀(Service/Manager/Helper)、业务摘要存在性。写代码时 IDE 实时波浪线、编译时警告,消费者可用 .editorconfig 一行升级为硬门禁:

dotnet_diagnostic.AEGINT003.severity = error

业务仓经 NuGet 引用自动生效、零配置(包内 analyzers/dotnet/cs 目录);本仓源码内引用需直连 Analyzers 项目——Analyzer 项经 ProjectReference 不跨级传递。

配套阅读​