
本文使用 Java 21 和 Spring Boot 3.3.6 实现一个多 Provider 路由示例业务通过统一接口调用Spring 收集多个实现路由按业务标识选取适配器再由适配器校验参数、转换请求并返回字节数组。需要处理的边界包括默认 Provider、null 参数、名称归一化、重复注册、未知名称和不支持的输出格式。本文使用独立的 Spring Boot 教学工程所有接口、参数协议和数据均为示例自定义。两个本地 Fake Provider 只返回测试标记不连接真实服务也不生成可播放音频。1. 定义业务接口与 Provider 契约示例将调用接口分成两层。业务调用SpeechSynthesizer.synthesize(text, voice, options)得到byte[]供应方适配器实现ProviderSpeechSynthesizer额外声明自己的provider()。示例接口节选省略包声明SynthesisOptions是同一工程中的参数 recordpublicinterfaceProviderSpeechSynthesizer{Stringprovider();byte[]synthesize(Stringtext,Stringvoice,SynthesisOptionsoptions);}业务层不需要知道请求字段应该叫text还是content也不需要处理网关返回的资源地址。路由层选择实现适配器处理协议差异。示例使用direct和url两个虚构实现。{MapString,ProviderSpeechSynthesizerregisterednewLinkedHashMap();for(ProviderSpeechSynthesizersynthesizer:synthesizers){StringnamenormalizeRegistrationName(synthesizer.provider());if(registered.putIfAbsent(name,synthesizer)!null){thrownewIllegalArgumentException(Provider 重复注册name);}}providersCollections.unmodifiableMap(registered);}Spring 可以把同一类型的多个 Bean 注入到集合中直接注入MapString, ProviderSpeechSynthesizer时键是 Bean 名。这里选 List是为了用provider()声明的标识作为路由键不让对外参数跟着 Java 类名或 Bean 命名一起变。Spring 集合注入说明示例默认选择direct名称先去掉两端空白再用Locale.ROOT转小写。请求中的legacy-url兼容到url它是保留别名不能作为正式注册名。未知请求名称会明确报错。注册和查询至少要使用一致的大小写与空白处理。否则注册时存入Direct查询时找direct就会出现接口明明存在却无法匹配的问题。两个实现归一化后同名应当尽早失败。示例用putIfAbsent检出重复再建立不可修改的路由表不靠注册顺序决定覆盖关系。3. 实现默认参数与名称校验默认 Provider 和默认参数对象是两件事。如果只在options null时选择默认 Provider却仍把 null 传给适配器适配器读取参数字段时依然可能出现空指针。默认名称解决“选谁”默认对象解决“传什么”应在统一入口补齐对象或者明确拒绝 null。下面的独立示例在入口处补齐参数对象。它使用direct、url两个本地 Fake Provider默认direct把legacy-url作为url的兼容别名。这些名字和兼容关系完全由示例定义。SynthesisOptions.java完整文件位于示例工程的org.example.tts包speed为倍率null 字段的默认格式与语速由适配器处理packageorg.example.tts;publicrecordSynthesisOptions(Stringprovider,StringoutputFormat,Doublespeed){publicstaticSynthesisOptionsdefaults(){returnnewSynthesisOptions(direct,mp3,1.0);}}RoutingSpeechSynthesizer.java完整文件依赖工程中的SpeechSynthesizer、ProviderSpeechSynthesizer和上面的 record已随工程编译和运行packageorg.example.tts;importjava.util.Collections;importjava.util.LinkedHashMap;importjava.util.List;importjava.util.Locale;importjava.util.Map;importorg.springframework.stereotype.Component;ComponentpublicclassRoutingSpeechSynthesizerimplementsSpeechSynthesizer{privatestaticfinalStringDEFAULT_PROVIDERdirect;privatestaticfinalStringLEGACY_URL_ALIASlegacy-url;privatefinalMapString,ProviderSpeechSynthesizerproviders;publicRoutingSpeechSynthesizer(ListProviderSpeechSynthesizersynthesizers){MapString,ProviderSpeechSynthesizerregisterednewLinkedHashMap();for(ProviderSpeechSynthesizersynthesizer:synthesizers){StringnamenormalizeRegistrationName(synthesizer.provider());if(registered.putIfAbsent(name,synthesizer)!null){thrownewIllegalArgumentException(Provider 重复注册name);}}providersCollections.unmodifiableMap(registered);}Overridepublicbyte[]synthesize(Stringtext,Stringvoice,SynthesisOptionsoptions){SynthesisOptionseffectiveoptionsnull?SynthesisOptions.defaults():options;StringnameresolveRequestedProvider(effective.provider());ProviderSpeechSynthesizerselectedproviders.get(name);if(selectednull){thrownewIllegalArgumentException(不支持的 Providername);}returnselected.synthesize(text,voice,effective);}privatestaticStringnormalizeRegistrationName(Stringprovider){if(providernull||provider.isBlank()){thrownewIllegalArgumentException(Provider 注册名不能为空);}Stringnameprovider.trim().toLowerCase(Locale.ROOT);if(LEGACY_URL_ALIAS.equals(name)){thrownewIllegalArgumentException(legacy-url 是保留的请求别名不能用作 Provider 注册名);}returnname;}privatestaticStringresolveRequestedProvider(Stringprovider){Stringnameprovidernull||provider.isBlank()?DEFAULT_PROVIDER:provider.trim().toLowerCase(Locale.ROOT);returnLEGACY_URL_ALIAS.equals(name)?url:name;}}构造器只做注册合成时只查表并调用一个实现。示例把重复注册和未知 Provider 写成中文参数错误不引入额外错误码框架。若接入 Web API可以再由异常处理层转换成接口响应本文的命令行工程没有这层。请求名称为空可以使用默认值适配器注册名为空却要拒绝。示例将正式注册名校验放进normalizeRegistrationName()把默认值和兼容别名留在resolveRequestedProvider()。legacy-url是保留的请求别名即使没有注册url也禁止用它注册适配器这样新增实现不会改变旧请求的去向。正式名称归一化后重名仍立即报错不依赖注册顺序决定覆盖关系。4. 按 Provider 适配参数统一参数对象方便调用但不同网关仍可能要求不同的字段和取值。下面这套协议专门用于演示适配过程不代表任何厂商接口。调用意图direct 教学网关url 教学网关文本与音色text、voicecontent、speakerId输出格式format接受 mp3/wavaudioFormat只接受 mp3语速ratePercent round((speed - 1) * 100)tempo speed结果形态直接返回测试字节返回本地 fixture URI再读取测试字节两种实现都把未传格式和语速处理为 mp3、1.0并要求语速为有限值且在 0.5–2.0 范围内。这是示例约定不是通用的供应方限制。格式字段仅用于演示校验和传参网关不会执行音频编码。例如传入 1.25 倍语速direct 网关收到整数 25url 网关收到倍率 1.25。整数换算会舍入因此不能用这两个字段推断真实音频效果相同。调用方只需表达意图换算规则应留在对应适配器中。对没有支持的格式示例直接报错让调用者知道要求未被满足。接入其他网关时同样应先明确能力和降级约定再决定是否接受某项参数。示例适配器方法节选两个类通过构造器持有各自的 Fake 网关AdapterSupport在同一工程中校验非空文本、音色、格式及有限且处于 0.5–2.0 的语速Overridepublicbyte[]synthesize(Stringtext,Stringvoice,SynthesisOptionsoptions){AdapterSupport.validateInput(text,voice);StringformatAdapterSupport.format(options,Set.of(mp3,wav),provider());doublespeedAdapterSupport.speed(options);// 这是教学协议的换算规则不代表任何真实厂商的 rate 定义。intratePercent(int)Math.round((speed-1.0)*100);returngateway.synthesize(newFakeDirectGateway.Request(text,voice,format,ratePercent));}示例UrlSpeechSynthesizer.synthesize方法节选gateway是该类持有的FakeUrlGatewayOverridepublicbyte[]synthesize(Stringtext,Stringvoice,SynthesisOptionsoptions){AdapterSupport.validateInput(text,voice);StringformatAdapterSupport.format(options,Set.of(mp3),provider());doublespeedAdapterSupport.speed(options);URIurigateway.requestAudioUrl(newFakeUrlGateway.Request(text,voice,format,speed));returngateway.readFixture(uri);}这两个方法分别承担参数转换和结果读取。路由器不需要知道网关字段也不参与数值换算。5. 将不同响应统一为字节数组返回结构的差异也应该收在适配器里。示例的 direct 网关直接提供测试字节url 网关先返回fixture://地址适配器再从内存读取结果。两条路径最终都返回byte[]不把资源地址的处理暴露给调用方。这样业务层拿到的仍是byte[]不用因为一个 Provider 返回地址而另写一条业务流程。地址响应意味着适配器还要完成一次读取合成接口成功不代表最终内容已经取到。非空字节也不等于有效音频下载地址即使返回 HTTP 200也可能给出 HTML 错误内容。将来连接远程接口时应检查 HTTP 状态、Content-Type 和空输出限制读取大小并按预期格式校验内容不能只相信扩展名或长度。远程音频地址应只接受受信任的协议和目标若允许跳转需要重新校验目标。连接和读取超时也应明确配置。本文的本地 fixture 没有实现 HTTP 下载这些是后续接入时需要补齐的边界。示例的direct网关直接返回测试字节url网关先返回fixture://地址再从内存 fixture 中读取测试标记字节。fixture://只在进程内存中读取未实现真实 HTTP 下载。选择byte[]作为统一结果适合本文讨论的非流式调用但这个接口没有显式表达时长、逐词时间戳或格式等元数据。文件头可能包含部分信息需要额外解析业务要直接使用这些数据时可以定义结构化结果类型。持续输出的音频也需要流式契约不能只给同一个参数对象继续加字段。非流式byte[]通常让整段结果驻留内存长音频、批量处理和并发请求会叠加占用。大结果可以考虑流式读取、临时文件或对象存储引用本文保持现有非流式方案不展开流式架构。6. 构建、运行与测试范围完整工程使用 Java 21、Maven 和 Spring Boot 3.3.6包含接口、两个适配器、本地网关及 JUnit 测试。它只依赖本地测试数据首次构建可能需要下载 Maven 依赖随后网关调用完全在本地。示例工程 ZIP。解压后进入tts-routing-demo目录。以下是命令说明不是 Java 源码cd tts-routing-demo mvn test mvn -DskipTests package java -jar target/tts-routing-demo-1.0.0.jar示例DemoApplication.demonstrate方法节选Spring 将SpeechSynthesizer注入参数StandardCharsets来自java.nio.charsetCommandLineRunner来自 Spring BootBeanCommandLineRunnerdemonstrate(SpeechSynthesizersynthesizer){returnargs-{byte[]directsynthesizer.synthesize(你好,demo-voice,null);byte[]urlsynthesizer.synthesize(你好,demo-voice,newSynthesisOptions( LEGACY-URL ,mp3,1.0));System.out.println(direct newString(direct,StandardCharsets.UTF_8));System.out.println(url newString(url,StandardCharsets.UTF_8));System.out.println(以上是本地测试标记不是可播放音频。);};}命令行程序通过 Spring 上下文注入统一接口分别调用默认 Provider 和兼容别名然后关闭上下文不启动 HTTP 服务。输出中的DEMO_ONLY是测试标记。修订后从最新 ZIP 解压运行18 项示例测试通过Maven 打包及 JAR 运行通过。覆盖正确路由与调用隔离、默认参数、大小写和空白、别名、未知名称、重复注册、保留别名注册拒绝、不支持格式、参数换算、两种响应路径及失败后再次调用。这些测试覆盖本地注册、路由、参数转换和 fixture 读取没有验证真实服务、音频播放、网络下载、压力、性能或成本。示例的成功只证明其自身契约。7. 扩展边界这套路由只根据名称调用一个适配器。示例没有自动故障切换、健康检查或负载均衡。把未知名称悄悄改成默认 Provider会掩盖调用错误在供应方超时后自动换一家也可能更换音色、重复计费或重复生成。是否重试、如何识别已提交的任务属于更上层的调用策略不能因为 Map 里有多个实现就默认具备。增加 Provider 时需要检查的不只是能否注册成功还包括参数语义、未支持能力、响应内容以及错误是否能被调用方理解。接口统一之后差异仍然存在只是有了明确的处理位置。