本文使用 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是同一工程中的参数 record:
publicinterfaceProviderSpeechSynthesizer{Stringprovider();byte[]synthesize(Stringtext,Stringvoice,SynthesisOptionsoptions);}业务层不需要知道请求字段应该叫text还是content,也不需要处理网关返回的资源地址。路由层选择实现,适配器处理协议差异。示例使用direct和url两个虚构实现。
{Map<String,ProviderSpeechSynthesizer>registered=newLinkedHashMap<>();for(ProviderSpeechSynthesizersynthesizer:synthesizers){Stringname=normalizeRegistrationName(synthesizer.provider());if(registered.putIfAbsent(name,synthesizer)!=null){thrownewIllegalArgumentException("Provider 重复注册:"+name);}}providers=Collections.unmodifiableMap(registered);}Spring 可以把同一类型的多个 Bean 注入到集合中;直接注入Map<String, 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_PROVIDER="direct";privatestaticfinalStringLEGACY_URL_ALIAS="legacy-url";privatefinalMap<String,ProviderSpeechSynthesizer>providers;publicRoutingSpeechSynthesizer(List<ProviderSpeechSynthesizer>synthesizers){Map<String,ProviderSpeechSynthesizer>registered=newLinkedHashMap<>();for(ProviderSpeechSynthesizersynthesizer:synthesizers){Stringname=normalizeRegistrationName(synthesizer.provider());if(registered.putIfAbsent(name,synthesizer)!=null){thrownewIllegalArgumentException("Provider 重复注册:"+name);}}providers=Collections.unmodifiableMap(registered);}@Overridepublicbyte[]synthesize(Stringtext,Stringvoice,SynthesisOptionsoptions){SynthesisOptionseffective=options==null?SynthesisOptions.defaults():options;Stringname=resolveRequestedProvider(effective.provider());ProviderSpeechSynthesizerselected=providers.get(name);if(selected==null){thrownewIllegalArgumentException("不支持的 Provider:"+name);}returnselected.synthesize(text,voice,effective);}privatestaticStringnormalizeRegistrationName(Stringprovider){if(provider==null||provider.isBlank()){thrownewIllegalArgumentException("Provider 注册名不能为空");}Stringname=provider.trim().toLowerCase(Locale.ROOT);if(LEGACY_URL_ALIAS.equals(name)){thrownewIllegalArgumentException("legacy-url 是保留的请求别名,不能用作 Provider 注册名");}returnname;}privatestaticStringresolveRequestedProvider(Stringprovider){Stringname=provider==null||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、voice | content、speakerId |
| 输出格式 | format,接受 mp3/wav | audioFormat,只接受 mp3 |
| 语速 | ratePercent = round((speed - 1) * 100) | tempo = speed |
| 结果形态 | 直接返回测试字节 | 返回本地 fixture URI,再读取测试字节 |
两种实现都把未传格式和语速处理为 mp3、1.0,并要求语速为有限值且在 0.5–2.0 范围内。这是示例约定,不是通用的供应方限制。格式字段仅用于演示校验和传参,网关不会执行音频编码。
例如传入 1.25 倍语速,direct 网关收到整数 25,url 网关收到倍率 1.25。整数换算会舍入,因此不能用这两个字段推断真实音频效果相同。调用方只需表达意图,换算规则应留在对应适配器中。
对没有支持的格式,示例直接报错,让调用者知道要求未被满足。接入其他网关时,同样应先明确能力和降级约定,再决定是否接受某项参数。
示例适配器方法节选,两个类通过构造器持有各自的 Fake 网关;AdapterSupport在同一工程中校验非空文本、音色、格式及有限且处于 0.5–2.0 的语速:
@Overridepublicbyte[]synthesize(Stringtext,Stringvoice,SynthesisOptionsoptions){AdapterSupport.validateInput(text,voice);Stringformat=AdapterSupport.format(options,Set.of("mp3","wav"),provider());doublespeed=AdapterSupport.speed(options);// 这是教学协议的换算规则,不代表任何真实厂商的 rate 定义。intratePercent=(int)Math.round((speed-1.0)*100);returngateway.synthesize(newFakeDirectGateway.Request(text,voice,format,ratePercent));}示例UrlSpeechSynthesizer.synthesize方法节选;gateway是该类持有的FakeUrlGateway:
@Overridepublicbyte[]synthesize(Stringtext,Stringvoice,SynthesisOptionsoptions){AdapterSupport.validateInput(text,voice);Stringformat=AdapterSupport.format(options,Set.of("mp3"),provider());doublespeed=AdapterSupport.speed(options);URIuri=gateway.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.charset,CommandLineRunner来自 Spring Boot:
@BeanCommandLineRunnerdemonstrate(SpeechSynthesizersynthesizer){returnargs->{byte[]direct=synthesizer.synthesize("你好","demo-voice",null);byte[]url=synthesizer.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 时,需要检查的不只是能否注册成功,还包括参数语义、未支持能力、响应内容,以及错误是否能被调用方理解。接口统一之后,差异仍然存在,只是有了明确的处理位置。