1. 背景
1.1 两套 OCR 的协议差异
系统在开发和测试阶段使用的是百度 OCR 异步 API。应用先上传文件并创建识别任务,再轮询任务状态,任务完成后下载识别结果。这套接口依赖公网服务和访问令牌,适合研发阶段快速接入和验证效果。
进入客户交付环境后,OCR 模型由客户在本地部署。应用不再调用云端异步任务接口,而是通过内网访问本地模型服务,使用 JSON 传递 Base64 文件内容,并在一次请求中同步获取识别结果。
两套服务提供的业务能力相同:输入一份文档,输出分页后的 OCR 结果。但它们的调用流程、请求格式和结果获取方式并不相同。系统既要保留现有的开发测试能力,又要适配客户的本地部署环境,因此不能简单地用本地接口替换百度接口,而需要在同一套业务流程下兼容两种实现。
第一次遇到这个问题时,很容易产生一个直接想法:既然本地接口要求 JSON,那就把原来的 @RequestPart 改成 @RequestBody。
这个修改会让本地接口恢复正常,却会破坏原来的云端接口。原因并不复杂:@RequestPart 和 @RequestBody 描述的是两份不同的 HTTP 契约。它们不是同一个接口的两种写法,也不能通过修改一个公共 Client 同时满足。
真正需要解决的问题不是“该用 form 还是 JSON”,而是:
当两个外部系统提供相同的业务能力,却使用完全不同的技术协议时,怎样让业务代码不被这些差异污染?
1.2 直接加分支为什么会逐渐失控
最直接的实现是在原来的 Gateway 中增加判断:
fun recognize(input: OcrInput): List<OcrParsedPage> =
when (properties.ocr.provider) {
BAIDU -> {
// multipart 上传
// 提交异步任务
// 轮询并下载结果
}
LOCAL -> {
// 转成 Base64
// 发送 JSON
// 解析同步响应
}
}
在只有两个分支、每个分支只有几行代码时,这种写法似乎没有问题。但继续实现后,差异会不断进入同一个类:
两组 URL、模型和认证配置;
两套请求 DTO;
两套响应 DTO;
同步与异步处理流程;
URL 图片下载与 Base64 解码;
不同的超时、错误码和异常处理。
这个类最终既负责业务能力,又负责供应商选择,还负责两种 HTTP 协议。增加第三种实现时,只能继续添加分支。
问题的根源不是 when 本身,而是三个不同职责被放进了同一个地方:
应用需要什么能力;
当前选择哪种实现;
每种实现如何与外部系统通信。
把这三个问题拆开,设计就会清晰很多。
2. 目标结构
最终结构如下:
flowchart LR
Service["OcrService<br/>业务编排"] --> Port["OcrRecognitionGateway<br/>应用层端口"]
Port --> Router["OcrGatewayRouter<br/>策略路由"]
Config["OCR provider 配置"] --> Router
Router --> BaiduAdapter["BaiduPaddleOcrGateway<br/>云端适配器"]
Router --> LocalAdapter["LocalPaddleOcrGateway<br/>本地适配器"]
BaiduAdapter --> BaiduClient["BaiduPaddleOcrClient<br/>multipart + 异步任务"]
LocalAdapter --> LocalClient["LocalPaddleOcrClient<br/>JSON + 同步响应"]
BaiduAdapter --> Domain["OcrParsedPage<br/>统一领域结果"]
LocalAdapter --> Domain
这张图可以从左向右理解:
OcrService只知道“我要识别文档”;OcrRecognitionGateway定义这项能力;OcrGatewayRouter根据配置选择实现;两个供应商适配器分别处理自己的协议;
不论调用哪一套接口,最终都返回统一的领域模型。
Spring 在这里主要完成两件事:创建这些对象,以及把对象注入到需要它们的位置。真正的边界仍然由接口和包结构决定。
2.1 先分清两个模式解决的问题
在继续看代码前,先把策略模式和适配器模式分开。它们经常出现在同一组类里,但解决的不是同一个问题。
可以把两条关系分别写成:
策略模式:OcrGatewayRouter --选择--> OcrProvider
适配器模式:OcrInput --Gateway 翻译--> 供应商请求
供应商响应 --Gateway 翻译--> OcrParsedPage
同一个 Gateway 可以同时参与两种模式:从 Router 向下看,它是一个可选择的策略;从外部 OCR 接口向上看,它又是一个协议适配器。设计模式描述的是对象之间的协作关系,不是规定一个类只能拥有一个身份。
3. 第一个抽象:应用层端口
应用层先定义自己需要的能力:
interface OcrRecognitionGateway {
fun recognize(input: OcrInput): List<OcrParsedPage>
}
为了突出核心设计,正文省略了异步任务恢复、任务提交回调等参数,完整项目可以根据业务需要补充。
这个接口最重要的地方不是名字,而是它的位置和依赖方向:它由应用层定义,基础设施层实现。
@Service
class OcrService(
private val recognitionGateway: OcrRecognitionGateway,
) {
fun recognize(input: OcrInput): List<OcrParsedPage> =
recognitionGateway.recognize(input)
}
OcrService 不再依赖 BaiduPaddleOcrGateway 或 LocalPaddleOcrGateway。对它来说,OCR 来自云端、本地进程还是测试桩都不重要。
这就是依赖倒置原则的实际用法:
业务代码不依赖某个具体外部系统,而是依赖自己定义的能力接口。
在六边形架构或端口与适配器架构中,这个接口通常被称为“出站端口”。这里沿用团队更熟悉的 Gateway 命名。
4. OcrProvider:把不同实现变成可选择的策略
策略模式的核心不是“算法一定很复杂”,而是多个实现完成同一个目标,并且调用方可以用同一种方式使用它们。
例如导航软件的目标都是“到达目的地”,但可以选择驾车、步行或骑行。调用方只需要提出起点和终点,不需要把每种路线算法写进自己的业务代码。
OCR 场景也是如此:百度和本地模型都完成“识别文档”这个目标,因此先用一个统一接口描述它们。
基础设施层还需要知道每个实现属于哪个供应商,因此定义内部策略接口:
internal interface OcrProvider {
val type: OcrProviderType
fun recognize(input: OcrInput): List<OcrParsedPage>
}
两个实现分别声明自己的类型:
@Component
class BaiduPaddleOcrGateway(...) : OcrProvider {
override val type = OcrProviderType.BAIDU
}
@Component
class LocalPaddleOcrGateway(...) : OcrProvider {
override val type = OcrProviderType.LOCAL
}
按照策略模式的经典角色,对应关系如下:
到这里解决的只是“选择谁来识别”,还没有解决百度和本地接口怎样调用。后者属于适配器模式。
这里为什么同时存在 OcrRecognitionGateway 和 OcrProvider?
它们虽然都有 recognize,但面向的对象不同:
OcrRecognitionGateway面向应用层,表达“系统需要 OCR 能力”;OcrProvider面向基础设施路由器,表达“我是某一种可选择的 OCR 实现”。
这个区分还有一个很实际的好处:应用层只会看到一个 OcrRecognitionGateway Bean,也就是后面介绍的 Router。如果两个供应商实现都直接实现应用层接口,Spring 注入时会发现多个候选 Bean,还需要引入 @Primary、@Qualifier 或额外的选择逻辑。
5. Router:选择策略,但不创建策略
Router 的代码并不复杂:
@Component
internal class OcrGatewayRouter(
properties: AppProperties,
providers: List<OcrProvider>,
) : OcrRecognitionGateway {
private val selectedProvider = properties.ocr.provider
private val providersByType = providers.associateBy(OcrProvider::type)
override fun recognize(input: OcrInput): List<OcrParsedPage> =
requireNotNull(providersByType[selectedProvider]) {
"OCR provider is not configured: $selectedProvider"
}.recognize(input)
}
对于刚接触 Spring 的读者,最需要理解的是构造参数中的这一行:
providers: List<OcrProvider>
Spring 启动时会扫描带有 @Component 的类,并创建对应对象。当某个 Bean 的构造函数需要 List<OcrProvider> 时,Spring 会把容器里所有实现该接口的 Bean 放进这个列表。
因此这里会自动获得:
[
BaiduPaddleOcrGateway,
LocalPaddleOcrGateway
]
Router 再通过 associateBy 把它们整理成:
BAIDU -> BaiduPaddleOcrGateway
LOCAL -> LocalPaddleOcrGateway
这段设计同时使用了两种思想:
策略模式:每个 Provider 是一种可替换的 OCR 策略;
注册表/路由模式:Router 保存策略映射,并根据配置选择。
它不是典型的工厂模式。工厂负责创建对象,而这里的对象由 Spring 创建,Router 只负责选择已有对象。
6. 适配器模式:为什么 HTTP Client 必须分开
适配器模式可以继续用转接头理解。电脑只接受 USB-C,显示器只提供 HDMI,我们通常不会修改电脑或显示器,而是在中间放一个能够完成转换的转接头。
在当前系统中:
系统内部接受
OcrInput,希望得到List<OcrParsedPage>;百度接口接受 multipart 文件,返回异步任务和下载地址;
本地接口接受 JSON Base64,直接返回同步结果。
系统接口与两套外部接口不兼容,因此需要两个不同的“转接头”。但在编写转接逻辑前,首先要让 HTTP Client 如实描述各自的外部协议。
供应商隔离后,两份 HTTP 契约可以各自保持真实形态。
云端接口上传文件二进制:
@HttpExchange
interface BaiduPaddleOcrClient {
@PostExchange(contentType = "multipart/form-data")
fun submitJob(
uri: URI,
@RequestHeader("Authorization") authorization: String,
@RequestPart("model") model: String,
@RequestPart("optionalPayload") optionalPayload: String,
@RequestPart("file") file: Resource,
): OcrJobResponse
}
这里还有一个容易误解的细节:同一个云端异步 API 在传入 fileUrl 时可以使用 application/json,而直接上传本地文件二进制时使用 multipart/form-data。本文代码选择的是后者,因此使用 @RequestPart。判断 Content-Type 时应先看实际传递的是 URL 还是文件,不能只看接口名称。
本地接口接收 JSON,其中 file 是 Base64 字符串:
@HttpExchange(accept = ["application/json"])
interface LocalPaddleOcrClient {
@PostExchange(contentType = "application/json")
fun parseLayout(
uri: URI,
@RequestBody request: LocalLayoutParsingRequest,
): LocalLayoutParsingResponse
}
现在,@RequestPart 和 @RequestBody 不再争夺同一个方法。它们分别描述各自服务端真正接受的请求。
需要特别说明:两个 HTTP Client 本身主要是外部接口的代理,还不是完整的适配器。真正完成请求转换、流程转换和响应转换的是下一层 Gateway。
7. Gateway 具体适配了什么
Client 只描述 HTTP 请求,Gateway 才负责把业务输入转换成供应商请求,并把供应商响应转换回领域结果。
以本地 OCR 为例,Gateway 完成了四步转换:
把
ByteArray文件内容编码为 Base64;根据文件扩展名生成本地接口需要的
fileType;把
OcrInput组装成LocalLayoutParsingRequest;把外部响应和 Base64 页面图片转换成
OcrParsedPage。
本地适配器的关键流程如下:
override fun recognize(input: OcrInput): List<OcrParsedPage> {
val encodedFile = Base64.getEncoder().encodeToString(input.content)
val response =
client.parseLayout(
uri = URI.create(properties.layoutParsingUrl),
request = LocalLayoutParsingRequest(file = encodedFile),
)
return response.result.layoutParsingResults.mapIndexed { index, result ->
OcrParsedPage(
pageIndex = index,
mainResult = result.toDomain(),
pageImageBytes = decodeBase64(result.inputImage),
)
}
}
这段代码可以按适配器模式的经典角色理解:
这里设计模式中的 Client 指“使用目标接口的调用方”,不是名字中带有 Client 的 HTTP 接口。LocalPaddleOcrClient 反而属于被适配的一端。
云端适配器则负责另一套流程:
文件二进制
-> multipart 提交任务
-> 获取 jobId
-> 轮询任务状态
-> 下载 JSONL 结果
-> 下载页面图片
-> 转换为 OcrParsedPage
两条流程差异很大,但差异全部停留在基础设施层。应用层只看到相同的输入和输出。
因此,LocalPaddleOcrGateway 和 BaiduPaddleOcrGateway 同时具有两个观察角度:
Router 把它们当作
OcrProvider,这是策略模式;它们把外部协议转换为系统模型,这是适配器模式。
简单来说,策略模式负责“选谁”,适配器模式负责“怎么翻译”。
8. 外部 DTO 为什么不能直接进入业务层
外部接口通常会返回大量技术字段,例如模型参数、检测阈值、下载地址和供应商错误码。如果业务代码直接使用这些 DTO,供应商协议就会逐渐扩散到整个系统。
因此我们在边界处做一次显式转换:
internal fun PaddlePageResult.toDomain(): OcrMainResult =
OcrMainResult(
width = width,
height = height,
blocks = blocks.map { block ->
OcrParsingBlock(
label = block.label,
content = block.content,
bbox = block.bbox,
)
},
)
这层转换经常被称为防腐层。它的作用不是消除所有字段复制,而是防止外部模型决定内部模型的形状。
本例中,云端和本地服务的外层响应不同,但部分页面结果都来自 PaddleOCR,结构确实相同。因此可以共享稳定的 PaddleOCR 结果 DTO,同时把云端任务响应和本地同步响应分别定义。共享的是已确认相同的协议片段,不是为了减少文件数量而强行复用。
9. 配置只负责选择,不负责实现逻辑
配置结构可以保持简单:
app:
ocr:
provider: ${OCR_PROVIDER:local}
baidu-ocr:
token: ${BAIDU_OCR_TOKEN:}
job-url: ${BAIDU_OCR_JOB_URL:}
model: PaddleOCR-VL
local-ocr:
layout-parsing-url: ${LOCAL_OCR_LAYOUT_URL:}
ocr-url: ${LOCAL_OCR_URL:}
对应的配置类型:
@ConfigurationProperties(prefix = "app")
data class AppProperties(
val ocr: OcrProperties = OcrProperties(),
val baiduOcr: BaiduOcrProperties = BaiduOcrProperties(),
val localOcr: LocalOcrProperties = LocalOcrProperties(),
)
data class OcrProperties(
val provider: OcrProviderType = OcrProviderType.LOCAL,
)
enum class OcrProviderType {
BAIDU,
LOCAL,
}
Spring Boot 会把 app.ocr.provider 绑定到枚举。配置值写成 local、LOCAL 或 Local 都能识别,但团队最好统一一种写法。
供应商的模型参数不需要全部放进配置。只有确实存在环境差异、部署差异或运维调整需求的参数才值得配置化。固定的接口默认值留在请求对象或适配器中,通常更容易维护。
10. 这套设计用了哪些模式
这些模式不是为了“用模式而用模式”。它们分别对应了真实变化:供应商会变化、HTTP 协议不同、业务输出必须稳定。
11. Spring 在背后做了什么
把 Spring 的行为拆开看,并没有太多魔法:
@ConfigurationPropertiesScan找到配置类,把 YAML 绑定成 Kotlin 对象;@Component和@Service把 Router、Adapter、Service 注册成 Bean;Spring 根据构造函数类型注入依赖;
List<OcrProvider>会得到全部供应商实现;OcrService需要一个OcrRecognitionGateway,容器中承担这个角色的是 Router;Router 根据配置把调用转发给对应 Adapter。
如果暂时不理解 Bean 生命周期,也可以先把 Spring 容器想象成一个帮我们创建对象并连接构造参数的对象仓库。设计是否清晰,仍然取决于每个接口表达的职责。
12. 怎样测试隔离是否真的成立
仅仅启动成功还不够。至少需要三类测试。
12.1 Router 测试
使用两个假的 Adapter,验证配置为 LOCAL 时只调用本地实现:
val router = OcrGatewayRouter(
properties = propertiesOf(OcrProviderType.LOCAL),
adapters = listOf(baiduAdapter, localAdapter),
)
router.recognize(sampleInput)
assertFalse(baiduAdapter.called)
assertTrue(localAdapter.called)
12.2 HTTP 契约测试
不调用真实 OCR 服务,而是使用 Mock Server 检查:
云端提交请求必须是
multipart/form-data,并包含file、model等 part;本地请求必须是
application/json,文件内容位于 JSON 的file字段;请求字段使用服务端要求的 camelCase 或 snake_case;
响应缺少可选字段时仍能正常反序列化。
12.3 Adapter 测试
给 Gateway 注入假的 Client,验证:
文件是否被正确转换为 Base64;
PDF 与图片的
fileType是否正确;页面图片是否被正确下载或解码;
外部响应是否转换为统一领域结果;
辅助 OCR 失败时是否按既定策略降级。
这三类测试分别保护“选对实现”“发对请求”和“转对结果”。
13. 常见误区
13.1 只抽一个公共 HTTP Client
业务能力相同,不代表 HTTP 契约相同。公共抽象应该建立在稳定的业务语义上,而不是建立在碰巧都使用 HTTP 这一点上。
13.2 在 Service 中判断供应商
这样会让应用层知道供应商名称,并随着供应商增加不断修改。选择逻辑应该集中在 Router,协议逻辑应该留在 Adapter。
13.3 让外部响应 DTO 充当领域模型
短期少写了一次转换,长期会让数据库、接口层和业务规则都依赖供应商字段。
13.4 为了复用而共享全部 DTO
只有确认语义和结构相同的协议片段才应该共享。云端任务状态与本地同步响应显然不是同一契约,应当分别建模。
13.5 把所有参数都做成配置
配置越多不代表扩展性越好。大量几乎不会变化的布尔参数会增加部署和排障成本。配置应该表达环境差异,代码默认值负责表达稳定策略。
14. 总结
这次改造最关键的结论并不是 multipart/form-data 和 application/json 应该怎么写,而是抽象应该放在哪里。
应用层抽象稳定的 OCR 能力;
Router 负责选择实现;
Adapter 负责吸收供应商差异;
Client 负责描述真实 HTTP 契约;
Mapper 负责保护内部领域模型。
当外部系统发生变化时,我们不需要把变化藏进一个越来越复杂的 Gateway,而是给变化安排一个清晰的边界。这正是设计模式最实际的价值。
15. 参考资料
附录 A:完整结构参考代码
下面给出一套可独立理解的结构代码。为了避免示例变成供应商字段手册,只保留与隔离设计有关的请求和响应字段。
A.1 目录结构
ocr/
application/
OcrRecognitionGateway.kt
OcrService.kt
domain/
OcrModels.kt
infrastructure/gateway/
OcrProvider.kt
OcrGatewayRouter.kt
BaiduPaddleOcrClient.kt
BaiduPaddleOcrGateway.kt
LocalPaddleOcrClient.kt
LocalPaddleOcrGateway.kt
OcrProviderModels.kt
config/
AppProperties.kt
HttpInterfaceConfig.kt
A.2 领域模型
package com.example.ocr.domain
data class OcrInput(
val fileName: String,
val content: ByteArray,
)
data class OcrParsedPage(
val pageIndex: Int,
val text: String,
val pageImageBytes: ByteArray,
)
A.3 应用层端口与服务
package com.example.ocr.application
import com.example.ocr.domain.OcrInput
import com.example.ocr.domain.OcrParsedPage
import org.springframework.stereotype.Service
interface OcrRecognitionGateway {
fun recognize(input: OcrInput): List<OcrParsedPage>
}
@Service
class OcrService(
private val recognitionGateway: OcrRecognitionGateway,
) {
fun recognize(input: OcrInput): List<OcrParsedPage> {
val pages = recognitionGateway.recognize(input)
require(pages.isNotEmpty()) { "OCR must return at least one page" }
return pages
}
}
A.4 配置对象与扫描入口
package com.example
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
@SpringBootApplication
@ConfigurationPropertiesScan
class ExampleApplication
package com.example.config
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "app")
data class AppProperties(
val ocr: OcrProperties = OcrProperties(),
val baiduOcr: BaiduOcrProperties = BaiduOcrProperties(),
val localOcr: LocalOcrProperties = LocalOcrProperties(),
)
data class OcrProperties(
val provider: OcrProviderType = OcrProviderType.LOCAL,
)
enum class OcrProviderType {
BAIDU,
LOCAL,
}
data class BaiduOcrProperties(
val token: String = "",
val jobUrl: String = "",
val model: String = "PaddleOCR-VL",
)
data class LocalOcrProperties(
val layoutParsingUrl: String = "",
)
A.5 OcrProvider 与 Router
package com.example.ocr.infrastructure.gateway
import com.example.config.AppProperties
import com.example.config.OcrProviderType
import com.example.ocr.application.OcrRecognitionGateway
import com.example.ocr.domain.OcrInput
import com.example.ocr.domain.OcrParsedPage
import org.springframework.stereotype.Component
internal interface OcrProvider {
val type: OcrProviderType
fun recognize(input: OcrInput): List<OcrParsedPage>
}
@Component
internal class OcrGatewayRouter(
properties: AppProperties,
providers: List<OcrProvider>,
) : OcrRecognitionGateway {
private val selectedProvider = properties.ocr.provider
private val providersByType = providers.associateBy(OcrProvider::type)
init {
require(providersByType.keys.containsAll(OcrProviderType.entries)) {
"OCR providers are incomplete"
}
}
override fun recognize(input: OcrInput): List<OcrParsedPage> =
requireNotNull(providersByType[selectedProvider]) {
"OCR provider is not configured: $selectedProvider"
}.recognize(input)
}
A.6 外部协议模型
package com.example.ocr.infrastructure.gateway
data class OcrJobResponse(
val data: OcrJobData?,
)
data class OcrJobData(
val jobId: String,
)
data class OcrJobStatusResponse(
val data: OcrJobStatusData?,
)
data class OcrJobStatusData(
val state: String,
val resultUrl: OcrResultUrl? = null,
)
data class OcrResultUrl(
val jsonUrl: String?,
)
data class LocalLayoutParsingRequest(
val file: String,
val fileType: Int,
val visualize: Boolean = true,
)
data class LocalLayoutParsingResponse(
val errorCode: Int,
val errorMsg: String,
val result: LocalLayoutParsingResult,
)
data class LocalLayoutParsingResult(
val layoutParsingResults: List<PaddlePageResult>,
)
data class PaddlePageResult(
val markdown: PaddleMarkdown,
val inputImage: String?,
)
data class PaddleMarkdown(
val text: String,
)
data class JsonlEnvelope(
val result: JsonlResult,
)
data class JsonlResult(
val layoutParsingResults: List<PaddlePageResult>,
)
A.7 两套 HTTP Client
package com.example.ocr.infrastructure.gateway
import org.springframework.core.io.Resource
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.service.annotation.GetExchange
import org.springframework.web.service.annotation.HttpExchange
import org.springframework.web.service.annotation.PostExchange
import java.net.URI
@HttpExchange
interface BaiduPaddleOcrClient {
@PostExchange(
contentType = "multipart/form-data",
accept = ["application/json"],
)
fun submitJob(
uri: URI,
@RequestHeader("Authorization") authorization: String,
@RequestPart("model") model: String,
@RequestPart("optionalPayload") optionalPayload: String,
@RequestPart("file") file: Resource,
): OcrJobResponse
@GetExchange(accept = ["application/json"])
fun getJobStatus(
uri: URI,
@RequestHeader("Authorization") authorization: String,
): OcrJobStatusResponse
@GetExchange
fun download(uri: URI): ByteArray
}
@HttpExchange(accept = ["application/json"])
interface LocalPaddleOcrClient {
@PostExchange(contentType = "application/json")
fun parseLayout(
uri: URI,
@RequestBody request: LocalLayoutParsingRequest,
): LocalLayoutParsingResponse
}
A.8 本地适配器
package com.example.ocr.infrastructure.gateway
import com.example.config.AppProperties
import com.example.config.OcrProviderType
import com.example.ocr.domain.OcrInput
import com.example.ocr.domain.OcrParsedPage
import org.springframework.stereotype.Component
import java.net.URI
import java.util.Base64
@Component
class LocalPaddleOcrGateway(
properties: AppProperties,
private val client: LocalPaddleOcrClient,
) : OcrProvider {
override val type = OcrProviderType.LOCAL
private val localProperties = properties.localOcr
override fun recognize(input: OcrInput): List<OcrParsedPage> {
val encodedFile = Base64.getEncoder().encodeToString(input.content)
val response =
client.parseLayout(
uri = URI.create(localProperties.layoutParsingUrl),
request =
LocalLayoutParsingRequest(
file = encodedFile,
fileType = if (input.fileName.endsWith(".pdf", true)) 0 else 1,
),
)
check(response.errorCode == 0) { response.errorMsg }
return response.result.layoutParsingResults.mapIndexed { index, result ->
val image = requireNotNull(result.inputImage)
OcrParsedPage(
pageIndex = index,
text = result.markdown.text,
pageImageBytes = Base64.getMimeDecoder().decode(image.substringAfter(',')),
)
}
}
}
A.9 云端适配器
package com.example.ocr.infrastructure.gateway
import com.example.config.AppProperties
import com.example.config.OcrProviderType
import com.example.ocr.domain.OcrInput
import com.example.ocr.domain.OcrParsedPage
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import org.springframework.core.io.ByteArrayResource
import org.springframework.stereotype.Component
import java.net.URI
@Component
class BaiduPaddleOcrGateway(
properties: AppProperties,
private val client: BaiduPaddleOcrClient,
) : OcrProvider {
override val type = OcrProviderType.BAIDU
private val baiduProperties = properties.baiduOcr
private val objectMapper = jacksonObjectMapper()
override fun recognize(input: OcrInput): List<OcrParsedPage> {
val file =
object : ByteArrayResource(input.content) {
override fun getFilename(): String = input.fileName
}
val submitted =
client.submitJob(
uri = URI.create(baiduProperties.jobUrl),
authorization = "bearer ${baiduProperties.token}",
model = baiduProperties.model,
optionalPayload = """{"useDocUnwarping":false}""",
file = file,
)
val jobId = requireNotNull(submitted.data?.jobId)
val results = waitForResults(jobId)
return results.mapIndexed { index, result ->
val imageUrl = requireNotNull(result.inputImage)
OcrParsedPage(
pageIndex = index,
text = result.markdown.text,
pageImageBytes = client.download(URI.create(imageUrl)),
)
}
}
private fun waitForResults(jobId: String): List<PaddlePageResult> {
val statusUrl = "${baiduProperties.jobUrl}/$jobId"
repeat(200) {
Thread.sleep(5_000)
val status =
client.getJobStatus(
uri = URI.create(statusUrl),
authorization = "bearer ${baiduProperties.token}",
)
when (status.data?.state) {
"done" -> {
val resultUrl = requireNotNull(status.data.resultUrl?.jsonUrl)
return parseJsonl(client.download(URI.create(resultUrl)))
}
"failed" -> error("OCR job failed")
}
}
error("OCR job timed out")
}
private fun parseJsonl(bytes: ByteArray): List<PaddlePageResult> =
bytes.toString(Charsets.UTF_8)
.lineSequence()
.filter(String::isNotBlank)
.flatMap { line ->
objectMapper.readValue<JsonlEnvelope>(line)
.result
.layoutParsingResults
.asSequence()
}.toList()
}
A.10 HTTP Interface 配置
package com.example.config
import com.example.ocr.infrastructure.gateway.BaiduPaddleOcrClient
import com.example.ocr.infrastructure.gateway.LocalPaddleOcrClient
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.client.RestClient
import org.springframework.web.client.support.RestClientAdapter
import org.springframework.web.service.invoker.HttpServiceProxyFactory
@Configuration
class HttpInterfaceConfig {
private fun factory(): HttpServiceProxyFactory =
HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(RestClient.create()))
.build()
@Bean
fun baiduPaddleOcrClient(): BaiduPaddleOcrClient =
factory().createClient(BaiduPaddleOcrClient::class.java)
@Bean
fun localPaddleOcrClient(): LocalPaddleOcrClient =
factory().createClient(LocalPaddleOcrClient::class.java)
}
实际项目还应补充连接超时、读取超时、统一异常转换和敏感信息脱敏。它们属于 HTTP 基础设施的生产化要求,不改变本文介绍的隔离结构。