一致性套件与门禁
这一页讲每个子系统的 <领域>.Conformance 包怎么用(继承抽象套件类、提供实现),插件协议的两份黑盒套件 Plugins.Conformance 与 xihan-conformance,以及框架仓库的架构测试与 CI 门禁。读完你能让自己写的 Provider 或插件跑完框架同一套用例,并知道向框架仓库提交代码要过哪些关。前置知识是单元与集成测试。
为什么需要
「换 Provider 只改包引用与配置」这句承诺要有人兜底:同一个 ICacheStore 契约,内存实现、Redis 实现和 Go 插件实现对过期、批量、并发写入的语义必须一致,否则应用在开发机上跑得好、上线换了 Provider 就出问题。框架为每个子系统抽象配一个一致性套件:一组 xUnit 抽象测试类,Provider 的测试项目继承基类、实现工厂方法,套件里的全部用例随之执行。框架自己的每个默认实现与 Provider 都在仓库里跑同一套用例,自己写的 Provider 也应当继承它。插件协议同理,任何语言写的插件都用同一份黑盒套件验证。
套件一览
每个套件包是一组 xUnit v3 抽象测试类,只引用 xUnit 的核心与断言包,不带运行器;测试项目要自己引用 xunit.v3。基类与要实现的工厂方法:
| 套件包 | 基类 | 实现什么 |
|---|---|---|
Caching.Conformance | CacheStoreConformance | CreateStore() 返回 ICacheStore;可覆写 ExpirationGrace |
Security.Conformance | PermissionGrantStoreConformance | CreateStoreAsync(grants) 返回装好授予的 IPermissionGrantStore;访问外部存储的 Provider 覆写 CreateUnavailableStoreAsync() |
Messaging.Conformance | MessageBrokerConformance / MessageStreamConformance / OutboxConformance | CreateFeature() 返回提供 messaging.queue@1(或流、持久化)的特性;可覆写 Configuration、DeliveryTimeout、SupportsMultipleInstances |
Persistence.Conformance | RepositoryConformance / DataSourceConformance / SchemaConformance / IdGeneratorConformance<TKey> | CreateFeature();数据源与结构套件另实现 CreateDefinition(name) 等;标识生成器实现 CreateGenerator(clock) |
MultiTenancy.Conformance | TenantStoreConformance / TenantShardResolverConformance | CreateStoreAsync(tenants) / CreateResolverAsync(tenants) |
ObjectStorage.Conformance | BlobStoreConformance / BlobStoreProviderConformance | CreateStore() 与 PlantOutsideContractAsync(...);CreateProvider()、ValidSettings()、OtherValidSettings() |
Localization.Conformance | MessageLocalizerConformance | CreateLocalizer(catalogs, options) |
Scheduling.Conformance | JobSchedulerConformance / JobStoreConformance / LeaderElectionConformance | StartAsync(configure, configuration) 返回 JobSchedulerHost;CreateAsync();CreateElection(clock) 与 StartNodeAsync(clock, probe) |
Workflow.Conformance | WorkflowStoreConformance / WorkflowEngineConformance / WorkflowTaskConformance | StartAsync() 或 StartAsync(configure, configuration) 返回套件的宿主句柄 |
Auditing.Conformance | AuditTrailConformance | CreateFixtureAsync() 返回 AuditTrailFixture |
Configuration.Conformance | RemoteConfigurationClientConformance / SecretStoreConformance | CreateFixtureAsync() 返回带被测客户端与写入入口的夹具 |
AI.Conformance | KnowledgeStoreConformance / AIProviderConformance | StartAsync() 返回套件的宿主句柄 |
Plugins.Conformance | PluginProtocolConformance / WasmPluginConformance | PluginDirectory:插件构建产物目录,见下文 |
以特性为入口的套件(持久化、消息、调度、工作流)自己用 TestHost 装配并走完整管道,所以它们验证的是 Provider 在真实管道里的行为,不只是接口方法。各套件的用例覆盖面见 Conformance。
最小示例
新建测试项目,引用被测 Provider、对应的 <领域>.Conformance 包与 xunit.v3,写一个 public sealed class 继承基类。Caching.InMemory 的测试项目就这样跑完整套缓存用例:
using XiHan.Framework.Caching.Abstractions.Stores;
using XiHan.Framework.Caching.Conformance;
using XiHan.Framework.Caching.InMemory;
public sealed class InMemoryCacheStoreConformanceTests : CacheStoreConformance
{
protected override ICacheStore CreateStore() => new InMemoryCacheStore(new InMemoryCachingOptions(), TimeProvider.System);
}以特性为入口的套件实现 CreateFeature();内存代理每个宿主一个、两个宿主不共用,所以把多实例用例关掉:
using XiHan.Framework.Kernel.Hosting;
using XiHan.Framework.Messaging.Conformance;
using XiHan.Framework.Messaging.InMemory;
public sealed class InMemoryMessageBrokerConformanceTests : MessageBrokerConformance
{
protected override bool SupportsMultipleInstances => false;
protected override IFeature CreateFeature() => new InMemoryMessagingFeature();
}运行 dotnet test --project <测试项目>,套件的每个 [Fact] 都作为这个类的用例出现:缓存套件会列出读写往返、键区分大小写、批量保持顺序、不存在才写只有一个赢家、滑动过期、分布式锁互斥等十几条,全部通过才算与默认实现语义一致。某条失败时,失败名就是语义差异所在,按它修 Provider,不要在派生类里跳过。
依赖真实服务的套件
依赖真实服务的 Provider(Redis、RabbitMQ、Kafka、PostgreSQL、SQL Server、MySQL、Oracle、S3、Nacos、Qdrant 等)在框架仓库里只在设置了对应环境变量时运行,没有就用 Assert.SkipWhen 跳过:XIHAN_TEST_REDIS、XIHAN_TEST_RABBITMQ、XIHAN_TEST_KAFKA、XIHAN_TEST_POSTGRES、XIHAN_TEST_SQLSERVER、XIHAN_TEST_MYSQL、XIHAN_TEST_ORACLE、XIHAN_TEST_S3、XIHAN_TEST_NACOS、XIHAN_TEST_QDRANT。CI 以服务容器提供这些服务。自己的 Provider 照这个做法:工厂方法里读环境变量,没有就跳过,这样本机没有服务也能跑其余用例,CI 再接真实服务。给不可达的存储做用例时只让被测存储的连接不可达,不要让整个宿主的库不可达:宿主里别的子系统的后台循环会如实报不可用,那是真实的故障信号而不是测试噪音。
Plugins.Conformance 与 xihan-conformance
插件协议有两份黑盒套件,任何语言写的插件都用它们验证:.NET 的 XiHan.Framework.Plugins.Conformance,和规范仓库 framework-spec 里语言中立的 Go 运行器 xihan-conformance。两者都以宿主身份启动插件、按规范核对,用例相同。
.NET 测试项目以插件的构建产物目录继承 PluginProtocolConformance(进程外)或 WasmPluginConformance(Wasm):
using XiHan.Framework.Plugins.Conformance;
public sealed class EchoPluginConformanceTests : PluginProtocolConformance
{
protected override string PluginDirectory => Path.Combine(AppContext.BaseDirectory, "plugins", "echo");
// 故意崩溃的操作不调用
protected override IReadOnlySet<string> ExcludedOperations => new HashSet<string>(StringComparer.Ordinal) { "echoes.crash" };
protected override IReadOnlyDictionary<string, string> SampleRequests => new Dictionary<string, string>(StringComparer.Ordinal) { ["echoes.create"] = """{"text":"conformance"}""" };
}
public sealed class EchoWasmPluginConformanceTests : WasmPluginConformance
{
protected override string PluginDirectory => Path.Combine(AppContext.BaseDirectory, "plugins", "echo-wasm");
protected override IReadOnlySet<string> ExcludedOperations => new HashSet<string>(StringComparer.Ordinal) { "echoes.trap", "echoes.spin" };
// 陷阱后重建用例用它触发陷阱
protected override string? TrapOperation => "echoes.trap";
}可覆写的成员:Timeout(每次协议调用,缺省 15 秒,进程外)、ConfigurationJson(Configure 下发的配置节,缺省 {})、SampleRequests(没有样例的操作以 {} 调用)、ExcludedOperations(故意崩溃或依赖宿主回调的操作)、TrapOperation(Wasm,给出时才跑陷阱后重建用例)。跑 Wasm 套件的测试项目在 Windows 上要设 <CETCompat>false</CETCompat>:套件会故意触发陷阱,影子栈强制时整个测试进程 fail-fast(Wasm 插件)。
非 .NET 的插件用 Go 运行器,不需要 .NET 环境:
go install github.com/xihanfun/framework-spec/conformance/cmd/xihan-conformance@latest
xihan-conformance plugin --dir ./bin --exclude echoes.crash --sample 'echoes.create={"text":"hi"}'
xihan-conformance plugin --dir ./bin --json
xihan-conformance vectors| 选项 | 含义 |
|---|---|
--dir | 插件目录(含 xihan.manifest.json 与 entry 指向的可执行文件),必填 |
--timeout | 每一步的截止时间,缺省 15s |
--config | Configure 下发的配置节 JSON,缺省 {} |
--sample op=json | 某个操作的请求样例,可重复;没有样例的操作以 {} 调用 |
--exclude op | 不调用的操作,可重复 |
--case name | 只跑指定用例,可重复 |
--json | 结果输出为 JSON |
--quiet | 不转发插件的 stderr |
用例:handshake(握手行、版本协商、原语层版本、清单一致、声明在清单范围内、目录至少一种区域)、refusal(没有共同版本时以 FailedPrecondition 拒绝)、lifecycle(Configure / Start / Health / WatchHealth / Stop,进程在宽限内退出)、unknown-operation(Unimplemented)、declared-operations(每个声明的操作都返回 status)、event(订阅投递得到确认,没有订阅时跳过)、stdin(关闭 stdin 后进程自行退出)。每个用例单独启动一次插件进程。退出码:0 全部通过(含跳过),1 有用例失败,2 参数或目录错误。xihan-conformance vectors 自检规范仓库 conformance/vectors/ 下的数据向量(错误码、Baggage 键、握手行、版本协商等的合法与非法样例),各语言 SDK 的单元测试也直接读这些文件。
两份套件都不提供宿主回调服务,Host.Invoke / Host.Publish 的令牌与权限、崩溃重启由宿主实现的测试覆盖。
架构测试与门禁
这一节面向向框架仓库提交扩展的人(贡献指南):framework/ 下的每个正式包都要通过下列门禁,CI 全部是必过项,不放宽、不绕过、不加 NoWarn。
| 门禁 | 位置 | 检查什么 |
|---|---|---|
| 编译 | framework/Directory.Build.props | TreatWarningsAsErrors、Nullable、AnalysisLevel=latest-recommended、EnforceCodeStyleInBuild(命名 IDE1006 与未使用 using IDE0005 在构建期即错误)、NuGet 高危 / 严重漏洞中断构建 |
| 公开面与包 | framework/src/Directory.Build.props | PublicAPI.Shipped.txt / PublicAPI.Unshipped.txt 必须与公开面一致、包验证、AOT 分析(IsAotCompatible 缺省为真) |
| 分析器 | Kernel.Analyzers | XH0001–XH0005:[ApiLevel]、XiHan 前缀、禁用词、Internal API、Ring 0/1 零反射(Kernel.Analyzers) |
| 架构测试 | framework/test/XiHan.Framework.Architecture.Tests | 见下表 |
| 冒烟 | CI | dotnet pack 触发包验证;samples/SimpleApp 以 Native AOT 发布 |
架构测试扫描源码目录与项目文件,按包 ID 推出环层再判定:
| 测试 | 规则 |
|---|---|
ProjectReferences_PointToLowerRingsOrSameRingAbstractions | 依赖方向:只能引用更低环层的包;同环只能引用对方的 .Abstractions;工具链与组合包例外 |
Primitives_HasNoPackageOrProjectReferences、KernelAndAbstractions_ReferenceOnlyMicrosoftExtensionsAbstractions | 原语层零依赖;Ring 0/1 与抽象包只引用 Microsoft.Extensions.* 抽象包或白名单里的包 |
PrimitivesAndKernel_DoNotUseReflection | 零反射:Ring 0/1 源码不使用反射 |
PublicSignatures_DoNotLeakThirdPartyTypes | 公开签名只出现 BCL、Microsoft.Extensions 与框架自身的类型 |
PackageIds_FollowGrammar、Vocabulary_IsWellFormed | 包 ID 文法:由术语表与规范的文法推出,见能力包与中间件 |
Namespaces_MatchFolders、Namespaces_EqualPackageIdWithAtMostOneSegment | 命名空间等于包 ID,子命名空间最多一层且与目录同名 |
PublicTypes_DoNotCarryXiHanPrefix | 公开类型名不带 XiHan 前缀 |
SourceFiles_DeclareOneTopLevelType_NamedLikeTheFile、SourceFiles_StartWithCopyrightHeader | 一个文件一个顶层类型、文件名与类型名一致(泛型加 OfT);版权头 |
XmlSummaries_UseTheThreeLineForm、XmlSummaries_AreOneConciseSentence | <summary> 三行写法、一句话,细节放 <remarks> |
EverySourceProject_HasReadmeAndPublicApiFiles、EverySourceProject_IsReferencedByArchitectureTests | 每个正式包都有 README.md 与两个 PublicAPI 文件,并加进架构测试项目的引用 |
EveryLocalizedPackage_HasOneFilePerLocale_WithConsistentKeys、LocaleFiles_AreEmbeddedWithTheConventionalLogicalName、MessageConstants_MatchTheEnglishLocaleFile、ErrorCallSites_UseMessageKeyConstants | 本地化:10 种区域的语言文件齐全、占位符一致、按约定嵌入、消息键常量与 en-US.json 一一对应、错误调用点只用常量(全球化和本地化) |
ErrorCodes_AreRegisteredOnce | 错误码全仓库唯一 |
DeclaredHealthItems_AreImplementedInTheSamePackage、PackagesRegisteringContributors_DeclareHealthItems | 清单 contributes.health 的条目由同一个包实现,登记了 IHealthContributor 的包必须声明条目(插件宿主例外);samples/ 下的示例项目同样受检 |
SourceProjects_DoNotCallSingleItemMethodsInsideLoops | 批量优先:不在循环里调用仓储、缓存存储、消息发布的单条方法,改用批量方法 |
DatabaseRecords_DeclareEveryStringColumnLength | 数据库记录的字符串列都声明 [StringLength(n)] 或 [LongText] |
RecordsCarryingCredentials_MaskThemInToString | 携带凭据的记录在 ToString 里遮蔽凭据 |
常见问题
套件里有一条用例我的后端做不到,能跳过吗
先看基类有没有对应的虚成员:多实例、不可达存储、命名连接、直接写入入口这类差异都有开关,不覆写时相关用例自动跳过。没有开关的用例代表契约语义,做不到说明实现与契约不一致,要改实现或在应用里不声明这个能力。
套件要连真实服务,本机没有怎么办
工厂方法里读 XIHAN_TEST_* 环境变量,没有就 Assert.SkipWhen 跳过;CI 用服务容器提供。本机要跑时用 Docker 起一个服务并设环境变量即可。
插件套件和宿主测试有什么区别
Plugins.Conformance 与 xihan-conformance 验证插件是否遵守协议(握手、生命周期、操作应答、事件确认、stdin 退出),不提供宿主回调。回调权限、会话令牌、崩溃重启、代理操作经管道这些是宿主行为,由宿主包自己的测试覆盖;写插件只需过前者。
我的应用不向框架仓库提交,架构门禁与我有关吗
包 ID 文法只约束 XiHan.Framework.*,但清单 ID、能力 ID、错误码与健康项的文法由内核在运行期校验,照样适用;本地化与批量优先的规则是框架默认实现的约定,自己的 Provider 遵守它们才能通过一致性套件。想让编译器执行同样的类型命名规则,启用 Kernel.Analyzers。
相关包与下一步
- 包参考:Conformance、Testing、Kernel.Analyzers、Kernel.Generators
- 相关章节:单元与集成测试、进程外插件、Wasm 插件、贡献指南
- 规范:RFC-0017《进程外插件协议》、RFC-0018《Wasm 插件 ABI》;规范仓库 framework-spec
- 下一步:写一个 Provider把一致性套件用在自己的实现上
