第三方集成(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 不是必需的——两三个字段的直接赋值写在适配器方法里也能跑。但一旦转换涉及字段对照、格式处理(日期、编码)、条件分支,就值得拆出来,收益有三条:
- 可以脱离 HTTP 单测。 转换是对接中最容易出错的部分。独立成类后它是纯函数:统一模型进、厂商形状出,测试里直接
new出来断言输入输出即可,不用启动容器、不用 mock 传输;写在适配器里,测转换就得连传输一起测。 - 改动隔离。 现场最常动的就是字段对照(医院新增科室、厂商调整回执格式)。独立类让这类变更的 diff 只落在 Transformer 一个文件,评审聚焦、回归范围明确。
- 适配器只剩组装。 拿契约客户端、调转换、记日志、返回——所有适配器结构雷同,读代码的人一眼定位到"这家厂商的差异在转换"。
保持 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)——绑定决定"用哪家",实例按需创建、用完即弃。
为什么定在启动时,而不是运行中可换:
- 换实现是计划事件。 换厂商、切医院定制、升版本,都有通知、有窗口、有回退预案,重启在这个语境下不是代价。
- 运行中静默换绑是危险语义。 请求处理到一半换了厂商,同一笔业务前后两段打到两家,一致性无从谈起。
- 绑定确定得越早,问题暴露得越早。 绑错 Provider,启动预检(Warn)或首次解析(明确报错)就会拦住,而不是业务跑了半天才发生漂移。
解析器不缓存绑定决策,这是有意设计,目的是动态解析:源的数据在运行时被替换(数据库改表、灰度开关切换),下一次解析就按新数据路由。代价是源要承受每次解析的询问——自建带 I/O 的策略源必须加缓存(见自定义扩展)。绑定的新鲜度由策略源自己决定:将来接入数据库策略源(自带短缓存,见自定义扩展),改绑定表的生效窗口就是缓存过期时间,不用重启。"数据什么时候新鲜"交给扩展点回答,框架不替你决定。
策略源链与解析回退
策略源回答一个问题:某 Connector 用哪个 Provider。解析时按 Order 降序依次询问,先命中先用:
代码级策略源(业务自定义,高 Order,如 100)
│ 无意见
▼
Db 策略源(预留,约定 Order=10,本期未实现)
│ 无意见
▼
Config 策略源(ConfigIntegrationStrategySource,Order=0,读 Integration:Bindings)
命中绑定后,注册表里找不到对应 Provider 会在解析期直接报错,错误消息列出该 Connector 已注册的全部 Provider,不会静默降级。
没有命中任何绑定时走回退链:
- 唯一注册自动选中:某 Connector 只有一个适配器,不用配置,直接用它(接口先行、补一个实现的过渡期很好用)
- 多适配器无绑定,拒绝静默路由:启动期预检给 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", "");
})
边界语义三条:
- 兜底返回不等于业务成功。业务靠回执字段区分(
Accepted = false走挂起/对账流程),别把降级态伪装成成功——伪装了,对账就找不到该捞的单。 - 调用方主动取消永不吞。Fallback 不拦截 CancellationToken 的取消,原生传播——"用户不等了"和"第三方挂了"是两回事。
- 兜底值属于适配器知识。东软挂了返回什么、金域挂了返回什么可以不同(有的厂商有离线缓冲语义,有的没有),但都必须符合统一模型——这正是"兜底值只有调用方知道"的含义。
特性优先级、每尝试独立超时、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 包三条纪律,决定它能不能一直薄下去:
- 只装三样东西:
[Connector]接口、统一模型 record、对Aegis.Integration的引用。别的什么都不进。 - 统一模型必须自含。写 Connector 接口时想引用业务实体或枚举,说明统一模型还没提炼干净,这是模型的问题,不是布局的问题。同理,想照抄厂商字段命名,说明抽象方向反了——Connector 从业务正向抽象(见核心概念),厂商文档不是它的设计输入。
- 加法式演进加 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)。
适配器传给 BrokerClient.Get<T> 的 baseUrl,对接 WebService(SOAP 契约,带 [ServiceContract])时必须非空——null 会直接抛 ArgumentNullException;REST 契约为空时回退契约 [BasePath]。
自定义扩展
策略源怎么写
IIntegrationStrategySource 回答一个问题:这个 Connector 用哪个 Provider。返回 Provider 名;没意见返回 null,链去问下一个源。
三条规则:
- 只查不改。每次解析都会调这个方法,别在里面做状态变更;带 I/O 的源要加缓存,用缓存装饰器包一行就行。
- 抛异常前想清楚。异常会一路传到业务调用点。抛出去,还是 catch 后返回 null(链落到 Config),由源自己决定。
- 返回的 Provider 必须真实存在。指到没注册的 Provider,解析器直接报错,错误里带已注册清单,不会静默路由。
Order 决定谁先被问:大的先问,命中即停,同 Order 按注册顺序。
| Order | 层 | 用途 |
|---|---|---|
| 100+ | 代码/计算源 | 灰度、开关、显式覆盖 |
| 10 | 数据库源 | 运维改表调绑定 |
| 0 | Config 源(内置) | 随应用发布的基线 |
缓存装饰器: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>());
两条纪律:
- Resolve 只读,变更走外部入口。反模式是在 Resolve 里顺手改状态(计数、自动切换)——解析器高频调用会让状态不可预期地漂移;状态由管理端点或配置中心显式设置则完全成立。需要"切换后固化",把状态落到 Db 源的表里。
- 这种源不要包缓存装饰器。它的价值就是每次解析读到最新值,包了缓存等于把秒级切换拖到 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 | 本组件 | 框架 | 装配层总称 |
| Connector | IConnector 派生接口 + [Connector("Lis")] | 业务侧 | 从业务能力正向抽象出的集成边界,接口名 = 业务能力 |
| Resolver | IIntegrationResolver | 框架 | 按策略源链选适配器,把接口解析成实例 |
| Adapter | [Adapter(Provider)] 类 + AdapterBase | 实现侧 | 实现转接(Provider 标识实现方),出入参形状与字段映射的转换在这里发生 |
| Transformer | 业务自定类 | 实现侧 | 字段/结构转换,形态自选 |
| Broker Contract | Aegis.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 之前不要写代码,先向开发者问清这些问题,拿到答案再动手:
- 这个 Connector 代表什么业务能力? 要一个业务动作的答案(送检医嘱、查询报告);回答是"对接东软的 XXX 接口"就追问业务目的。
- 业务真正需要哪些方法? 厂商有但业务不用的不加;业务要但厂商拆成三个调用的,合成一个方法。
- 出入参的业务字段是什么? 统一模型用业务语义命名,厂商字段(蛇形命名、条码制)只允许出现在 Broker Contract、DTO、Transformer 一侧。
- 预期按什么维度换实现? 厂商、医院还是版本——决定 Provider 值和接口切分粒度。
- 第三方完全不可用时,这个方法该返回什么? 降级态字段(如
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 不跨级传递。
配套阅读
- 远程调用客户端(Aegis.Net.Broker):Broker Contract 的特性体系(
[Post]、[Body]、[BasePath])、REST 与 SOAP 调用细节 - 自定义组件注入:
Component.deps.json的Load装配机制 - 程序集扫描与启动治理:宿主扫描面的组成与
AegisBuilder.Assemblies出口的来龙去脉