接入两套 TTS 接口后,业务代码如何避免堆满 if/else?

一段文本、一套音色参数,最后拿到音频字节------业务调用看起来很简单。但接上两套 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 时,需要检查的不只是能否注册成功,还包括参数语义、未支持能力、响应内容,以及错误是否能被调用方理解。接口统一之后,差异仍然存在,只是有了明确的处理位置。

相关推荐
geovindu3 小时前
rust: pool Pattern (续)
开发语言·后端·设计模式·rust·对象池简写模式
2302_805771071 天前
Day23:POM设计模式(封装)
java·开发语言·设计模式
范中勤2 天前
技术文档:模型工厂 `llm_provider` / `llm_use` 分层设计
设计模式·langchain·架构设计·模型工厂·llm_provider
浅尝辄止;2 天前
设计模式在HIS/LIS医疗系统中的完整实践指南(C#版)
开发语言·设计模式·c#
范中勤2 天前
适配器模式(Adapter Pattern):原理、场景与 LLM 多厂商工程落地
设计模式·架构·langchain·适配器模式·多厂商
408高手3 天前
23种设计模式
设计模式
沫璃染墨3 天前
《Linux工程实践篇(二):线程池实战——从线程安全单例到完整源码实现》
linux·开发语言·c++·单例模式·设计模式·系统架构
sarasuki3 天前
MCP 为什么是一个协议而不是一个框架呢?
设计模式·agent·mcp
geovindu3 天前
rust: Borg Pattern(续)
开发语言·后端·设计模式·rust·博格模式