一段文本、一套音色参数,最后拿到音频字节------业务调用看起来很简单。但接上两套 TTS 接口以后,请求字段、参数范围和返回方式都可能不同。如果这些判断散落在业务方法里,新增一个接口时,就得重新检查每处调用。
示例采用"业务入口 → 路由 → Provider 适配器"的分工。这里值得展开的并不是策略模式这个名字,而是哪些判断放进路由,哪些差异必须留在适配器里。
本文使用独立的 Spring Boot 教学工程,所有接口、参数协议和数据均为示例自定义。两个本地 Fake Provider 只返回测试标记,不连接真实服务,也不生成可播放音频。
业务入口先保持稳定
示例将调用接口分成两层。业务调用 SpeechSynthesizer.synthesize(text, voice, options),得到 byte[];供应方适配器实现 ProviderSpeechSynthesizer,额外声明自己的 provider()。
示例接口节选,省略包声明;SynthesisOptions 是同一工程中的参数 record:
java
public interface ProviderSpeechSynthesizer {
String provider();
byte[] synthesize(String text, String voice, SynthesisOptions options);
}
业务层不需要知道请求字段应该叫 text 还是 content,也不需要处理网关返回的资源地址。路由层选择实现,适配器处理协议差异。示例使用 direct 和 url 两个虚构实现。

图中只展示教学工程的职责分工,不是实际服务的运行时序。Provider Map 存的是适配器对象,不是请求结果,也不负责缓存音频。
用 Provider 标识建表
RoutingSpeechSynthesizer 的构造器接收 List<ProviderSpeechSynthesizer>,再用各实现返回的业务标识建表。
示例构造器节选;normalizeRegistrationName 校验正式注册名,providers 是成员 Map:
java
public RoutingSpeechSynthesizer(List<ProviderSpeechSynthesizer> synthesizers) {
Map<String, ProviderSpeechSynthesizer> registered = new LinkedHashMap<>();
for (ProviderSpeechSynthesizer synthesizer : synthesizers) {
String name = normalizeRegistrationName(synthesizer.provider());
if (registered.putIfAbsent(name, synthesizer) != null) {
throw new IllegalArgumentException("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 检出重复,再建立不可修改的路由表,不靠注册顺序决定覆盖关系。
默认值要覆盖整条调用链
默认 Provider 和默认参数对象是两件事。如果只在 options == null 时选择默认 Provider,却仍把 null 传给适配器,适配器读取参数字段时依然可能出现空指针。默认名称解决"选谁",默认对象解决"传什么";应在统一入口补齐对象,或者明确拒绝 null。
下面的独立示例在入口处补齐参数对象。它使用 direct、url 两个本地 Fake Provider,默认 direct,把 legacy-url 作为 url 的兼容别名。这些名字和兼容关系完全由示例定义。
SynthesisOptions.java 完整文件,位于示例工程的 org.example.tts 包;speed 为倍率,null 字段的默认格式与语速由适配器处理:
java
package org.example.tts;
public record SynthesisOptions(String provider, String outputFormat, Double speed) {
public static SynthesisOptions defaults() {
return new SynthesisOptions("direct", "mp3", 1.0);
}
}
RoutingSpeechSynthesizer.java 完整文件,依赖工程中的 SpeechSynthesizer、ProviderSpeechSynthesizer 和上面的 record,已随工程编译和运行:
java
package org.example.tts;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.stereotype.Component;
@Component
public class RoutingSpeechSynthesizer implements SpeechSynthesizer {
private static final String DEFAULT_PROVIDER = "direct";
private static final String LEGACY_URL_ALIAS = "legacy-url";
private final Map<String, ProviderSpeechSynthesizer> providers;
public RoutingSpeechSynthesizer(List<ProviderSpeechSynthesizer> synthesizers) {
Map<String, ProviderSpeechSynthesizer> registered = new LinkedHashMap<>();
for (ProviderSpeechSynthesizer synthesizer : synthesizers) {
String name = normalizeRegistrationName(synthesizer.provider());
if (registered.putIfAbsent(name, synthesizer) != null) {
throw new IllegalArgumentException("Provider 重复注册:" + name);
}
}
providers = Collections.unmodifiableMap(registered);
}
@Override
public byte[] synthesize(String text, String voice, SynthesisOptions options) {
SynthesisOptions effective = options == null ? SynthesisOptions.defaults() : options;
String name = resolveRequestedProvider(effective.provider());
ProviderSpeechSynthesizer selected = providers.get(name);
if (selected == null) {
throw new IllegalArgumentException("不支持的 Provider:" + name);
}
return selected.synthesize(text, voice, effective);
}
private static String normalizeRegistrationName(String provider) {
if (provider == null || provider.isBlank()) {
throw new IllegalArgumentException("Provider 注册名不能为空");
}
String name = provider.trim().toLowerCase(Locale.ROOT);
if (LEGACY_URL_ALIAS.equals(name)) {
throw new IllegalArgumentException("legacy-url 是保留的请求别名,不能用作 Provider 注册名");
}
return name;
}
private static String resolveRequestedProvider(String provider) {
String name = provider == null || provider.isBlank()
? DEFAULT_PROVIDER : provider.trim().toLowerCase(Locale.ROOT);
return LEGACY_URL_ALIAS.equals(name) ? "url" : name;
}
}
构造器只做注册;合成时只查表并调用一个实现。示例把重复注册和未知 Provider 写成中文参数错误,不引入额外错误码框架。若接入 Web API,可以再由异常处理层转换成接口响应,本文的命令行工程没有这层。
请求名称为空可以使用默认值,适配器注册名为空却要拒绝。示例将正式注册名校验放进 normalizeRegistrationName(),把默认值和兼容别名留在 resolveRequestedProvider()。legacy-url 是保留的请求别名,即使没有注册 url,也禁止用它注册适配器;这样新增实现不会改变旧请求的去向。正式名称归一化后重名仍立即报错,不依赖注册顺序决定覆盖关系。
语速和音量,不能只改字段名
统一参数对象方便调用,但不同网关仍可能要求不同的字段和取值。下面这套协议专门用于演示适配过程,不代表任何厂商接口。
| 调用意图 | 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 的语速:
java
@Override
public byte[] synthesize(String text, String voice, SynthesisOptions options) {
AdapterSupport.validateInput(text, voice);
String format = AdapterSupport.format(options, Set.of("mp3", "wav"), provider());
double speed = AdapterSupport.speed(options);
// 这是教学协议的换算规则,不代表任何真实厂商的 rate 定义。
int ratePercent = (int) Math.round((speed - 1.0) * 100);
return gateway.synthesize(new FakeDirectGateway.Request(text, voice, format, ratePercent));
}
示例 UrlSpeechSynthesizer.synthesize 方法节选;gateway 是该类持有的 FakeUrlGateway:
java
@Override
public byte[] synthesize(String text, String voice, SynthesisOptions options) {
AdapterSupport.validateInput(text, voice);
String format = AdapterSupport.format(options, Set.of("mp3"), provider());
double speed = AdapterSupport.speed(options);
URI uri = gateway.requestAudioUrl(new FakeUrlGateway.Request(text, voice, format, speed));
return gateway.readFixture(uri);
}
这两个方法分别承担参数转换和结果读取。路由器不需要知道网关字段,也不参与数值换算。
音频地址在适配器里读完
返回结构的差异也应该收在适配器里。示例的 direct 网关直接提供测试字节;url 网关先返回 fixture:// 地址,适配器再从内存读取结果。两条路径最终都返回 byte[],不把资源地址的处理暴露给调用方。
这样业务层拿到的仍是 byte[],不用因为一个 Provider 返回地址而另写一条业务流程。地址响应意味着适配器还要完成一次读取:合成接口成功,不代表最终内容已经取到。
非空字节也不等于有效音频:下载地址即使返回 HTTP 200,也可能给出 HTML 错误内容。将来连接远程接口时,应检查 HTTP 状态、Content-Type 和空输出,限制读取大小,并按预期格式校验内容,不能只相信扩展名或长度。
远程音频地址应只接受受信任的协议和目标;若允许跳转,需要重新校验目标。连接和读取超时也应明确配置。本文的本地 fixture 没有实现 HTTP 下载,这些是后续接入时需要补齐的边界。
示例的 direct 网关直接返回测试字节;url 网关先返回 fixture:// 地址,再从内存 fixture 中读取测试标记字节。fixture:// 只在进程内存中读取,未实现真实 HTTP 下载。
选择 byte[] 作为统一结果,适合本文讨论的非流式调用,但这个接口没有显式表达时长、逐词时间戳或格式等元数据。文件头可能包含部分信息,需要额外解析;业务要直接使用这些数据时,可以定义结构化结果类型。持续输出的音频也需要流式契约,不能只给同一个参数对象继续加字段。
非流式 byte[] 通常让整段结果驻留内存,长音频、批量处理和并发请求会叠加占用。大结果可以考虑流式读取、临时文件或对象存储引用;本文保持现有非流式方案,不展开流式架构。
把路由和协议分别验证
完整工程使用 Java 21、Maven 和 Spring Boot 3.3.6,包含接口、两个适配器、本地网关及 JUnit 测试。它只依赖本地测试数据;首次构建可能需要下载 Maven 依赖,随后网关调用完全在本地。
示例工程 ZIP。解压后进入 tts-routing-demo 目录。以下是命令说明,不是 Java 源码:
text
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:
java
@Bean
CommandLineRunner demonstrate(SpeechSynthesizer synthesizer) {
return args -> {
byte[] direct = synthesizer.synthesize("你好", "demo-voice", null);
byte[] url = synthesizer.synthesize("你好", "demo-voice",
new SynthesisOptions(" LEGACY-URL ", "mp3", 1.0));
System.out.println("direct => " + new String(direct, StandardCharsets.UTF_8));
System.out.println("url => " + new String(url, StandardCharsets.UTF_8));
System.out.println("以上是本地测试标记,不是可播放音频。");
};
}
命令行程序通过 Spring 上下文注入统一接口,分别调用默认 Provider 和兼容别名,然后关闭上下文,不启动 HTTP 服务。输出中的 DEMO_ONLY 是测试标记。
修订后从最新 ZIP 解压运行:18 项示例测试通过,Maven 打包及 JAR 运行通过。覆盖正确路由与调用隔离、默认参数、大小写和空白、别名、未知名称、重复注册、保留别名注册拒绝、不支持格式、参数换算、两种响应路径及失败后再次调用。
这些测试覆盖本地注册、路由、参数转换和 fixture 读取,没有验证真实服务、音频播放、网络下载、压力、性能或成本。示例的成功只证明其自身契约。
新增实现之后还要检查什么
这套路由只根据名称调用一个适配器。示例没有自动故障切换、健康检查或负载均衡。
把未知名称悄悄改成默认 Provider,会掩盖调用错误;在供应方超时后自动换一家,也可能更换音色、重复计费或重复生成。是否重试、如何识别已提交的任务,属于更上层的调用策略,不能因为 Map 里有多个实现就默认具备。
增加 Provider 时,需要检查的不只是能否注册成功,还包括参数语义、未支持能力、响应内容,以及错误是否能被调用方理解。接口统一之后,差异仍然存在,只是有了明确的处理位置。