Feign 接口治理与 DelegatingService 模式

random-pic-api

一个奇怪的现象

上一篇讲到版本兼容性验证。等我把 Spring Boot 3.4.7 和 Spring Cloud 的版本问题解决之后,开始仔细看原来 2.1.0 微服务架构中的 Feign 接口关系,发现了一个让我困惑的现象。

同一套架构里,Feign 接口有两种完全不同的实现方式。一部分 Feign 接口由服务提供方维护,通过独立 jar 包对外发布——比如 sctelcp-user-api-starter,里面封装了 UserApiService 这个 Feign 接口以及相关的 DTO、枚举、常量。业务方引入这个依赖就能直接注入使用。这是第一种方式,很标准的”契约优先”做法。

另一部分 Feign 接口由业务方自己写——比如 sctelcp-infrastructure-service 自己维护了一个 UserApiService,用于调用用户服务的接口。同一个 UserApiService,在不同的服务里有不同的实现,甚至类名都一样。

于是我问了自己一个问题:为什么不全部用第一种方式?

答案是”以前出现过业务方乱调用 Feign 接口的情况,出了问题第一时间找架构组,所以就同时用两种方式——服务提供方只暴露必要的接口,其他接口业务方自己写,调坏了是自己的事。”

这个解释我当时就觉得站不住脚。如果真的是要防止业务方乱调用,那 sctelcp-user-api-starter 这个模块就不应该存在——因为它一旦被引入,就等于把所有封装的 Feign 接口和 DTO 都暴露给了业务方。事实是这个模块存在了,而且被十几个组件依赖。所以”防乱调用”这个理由在逻辑上是矛盾的。

但这不是我要讨论的重点。真正有意思的问题是:当你需要让一套代码同时支持单体架构和微服务架构时,这两种 Feign 接口的风格会对你的迁移路径产生什么影响?

从微服务到单体:DelegatingService 的诞生

先看历史。在第一次改造中(微服务 2.1.0 → 单体 cirrus-1.0.0),核心任务是把 Feign 远程调用改成 JVM 内部调用。怎么做的?

当时设计了一个聚合组件 sctelcp-infrastructure-service-starter。这个组件的作用是:

  1. 聚合单体架构所需的全部 starter(auth、user、gateway 等)
  2. 将原微服务中的 Feign 调用改为内部 Service 调用

第二个作用的实现方式就是 DelegatingXxxService。拿 DelegatingMessageUserApiService 举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class DelegatingMessageUserApiService implements MessageUserApiService {
@Resource
private WechatUserService wechatUserService;

@Override
public Response<?> bindPublic(String openid, String unionid) {
return wechatUserService.bindPublic(openid, unionid);
}

@Override
public Response<?> unBindPublic(String openid) {
return wechatUserService.unBindPublic(openid);
}
}

然后在装配类中注入:

1
2
3
4
5
@Bean
@ConditionalOnMissingBean
public MessageUserApiService messageUserApiService() {
return new DelegatingMessageUserApiService();
}

逻辑很简单:实现同一个接口,以前走 Feign 远程调用,现在直接注入本地 Service 调用。对于接口的调用方来说,注入的还是那个 MessageUserApiService,完全没有感知到后面的调用方式变了。

这个模式在当时干净利落地解决了从微服务到单体的改造问题。但它也为下一次改造埋下了一个雷——当你需要从单体再回到微服务时,DelegatingXxxService 怎么办?

从单体回到微服务:两种架构方案

单体架构中,sctelcp-infrastructure-service-starter 承担了三个职责:

  1. 聚合 auth、user、gateway 三个 starter 作为顶层依赖
  2. 通过 DelegatingXxxService 实现 Feign → JVM 内部调用
  3. 动态添加接口前缀(context-path),保证前端路径不感知架构变化

从单体切回微服务时,这三个职责都需要解绑:

  • 聚合依赖要拆开——微服务架构中,sctelcp-infrastructure-service 不应该引入 user-api-service-starterauth-service-starter 等重量级依赖,因为用户和认证是独立服务,应该通过远程调用访问
  • DelegatingService 不能留——微服务中不存在本地 Service 可调,必须改回 Feign 远程调用
  • 动态前缀要去掉——微服务有网关做路由,不需要在代码层加前缀

核心矛盾是:sctelcp-infrastructure-service-starter 怎么改才能被单体架构和微服务架构同时使用?

我们想了两种方案。

方案一:抽离 Delegating 模块,新增 Feign 模块

思路很清晰:

  • DelegatingXxxServiceinfrastructure-service-starter 中抽出去,单独成立一个 sctelcp-delegating-api-starter
  • infrastructure-service-starter 删掉 auth、user、gateway 的依赖,回归纯基础设施
  • 为微服务新增 feign-starter 组件(比如 sctelcp-user-api-feign-startersctelcp-common-security-feign-starter),专门提供 Feign 接口
  • feign-starter 依赖对应的 xxx-starter,复用 DTO 和接口定义

这样,单体架构引用 delegating-api-starter(内部调用),微服务引用 feign-starter(远程调用),两者共用 xxx-starter(接口定义 + DTO)。

方案二:在原有组件中同时实现 Delegating 和 Feign

思路更直接:

  • 不新增 delegating-api-starter
  • 不新增 feign-starter
  • DelegatingXxxService 放回对应组件内部(比如 DelegatingMessageUserApiService 放在 sctelcp-message-api-starter 中,通过 optional=true 引入 sctelcp-user-api-service-starter
  • Feign 接口也放在对应组件内部
  • 通过 @Conditional 注解在运行时判断——单体架构注入 Delegating 实现,微服务注入 Feign 实现

两种方案各有利弊。

方案一对单体架构友好——单体只需要多引一个 delegating-api-starter,依赖结构清晰,不会出现单体引入了微服务依赖的情况。缺点是新增了多个 feign-starter 模块,项目结构会膨胀。

方案二对微服务友好——不需要新增任何模块,改动量小。缺点是依赖关系混乱——为了实现 JVM 内部调用,你不得不用 optional=true 引入重量级的 service 依赖,这些依赖在微服务架构中是多余的,但它们以 optional 的形式存在着,随时间推移一定会出问题。

为什么选了方案一

最后拍板选了方案一。理由不是”哪个更优雅”,而是一个很现实的考虑。

方案二的 @Conditional 判断在跨版本维护时会变成一个巨大的认知负担。 想象一下,一个新加入的同事打开 sctelcp-message-api-starter,看到某个类里面有两个实现——一个用 Delegating 前缀,一个用 Remote 前缀,然后跑到装配类里看到一堆 @ConditionalOnProperty。他要明白为什么这个类有两种实现,就要理解”我们支持了单体架构和微服务架构两种部署模式”。而这件事对于一个只负责写业务接口的开发者来说,是他不应该被暴露到的复杂度。

方案一虽然新增了模块,但意图非常清晰。你打开 sctelcp-user-api-feign-starter,你就知道这里是 Feign 实现,用于微服务。你打开 sctelcp-delegating-api-starter,你就知道这里是为单体架构做的内部调用。模块名即文档。

这也是我在前面几篇文章里反复提到的一个原则:框架的复杂度不能转嫁给使用者。 如果为了少写几个模块而把复杂度藏在一堆条件装配里,那省下的只是框架开发者的时间,付出的却是每一个使用者的认知成本。

具体的实施

方案一确定后,需要做的事大致如下。

第一步:实现 delegating-api-starter

infrastructure-service-starter 中迁移出几个东西:

  1. DelegatingXxxService 5 个接口实现
  2. 自动装配类
  3. 动态添加接口前缀的逻辑

动态前缀的逻辑是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@AutoConfiguration
public class DelegatingInterfaceMappingConfig implements WebMvcConfigurer {
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.addPathPrefix("/user/api",
c -> c.getPackageName().startsWith("com.xxx.user.api.controller"));
configurer.addPathPrefix("/infrastructure/api",
c -> c.getPackageName().startsWith("com.xxx.infrastructure.controller"));
configurer.addPathPrefix("/user/auth",
c -> c.getPackageName().startsWith("com.xxx.user.auth.controller"));
configurer.addPathPrefix("/user/auth",
c -> c.getPackageName().startsWith("com.xxx.user.auth.endpoint"));
configurer.addPathPrefix("/gateway/api",
c -> c.getPackageName().startsWith("com.xxx.gateway"));
}
}

为什么要有这个逻辑?因为微服务里每个服务都配了 context-path,网关据此做路由。单体架构里没有网关,但前端请求路径不能变,所以只能在代码层通过包名匹配来添加路径前缀——你说这是个 hack,我也不否认。但它确实做到了”前端零改动”。

第二步:实现 feign-starter 组件

一共新增了 4 个 feign-starter:user-apicommon-securitymessage-apiserver-plugins

sctelcp-user-api-feign-starter 为例,它包含:

1
2
3
4
5
6
7
8
9
10
public class RemoteUserApiService implements UserApiService {
@Resource
private UserApiFeignService userApiFeignService;

@Override
public Response<Application> getClientDetailsByClientId(String clientId) {
return userApiFeignService.getClientDetailsByClientId(clientId);
}
// ... 其他实现
}

自动装配:

1
2
3
4
5
6
7
8
9
10
11
@AutoConfiguration
@EnableFeignClients(basePackages = "com.xxx.user.api")
@ComponentScan(basePackages = {"com.xxx.user.api"})
public class UserApiFeignAutoConfiguration {

@Bean
@Primary
public UserApiService userApiService() {
return new RemoteUserApiService();
}
}

这里有一个命名约定的变化值得说一下。在 2.1.0 版本中,同一个 UserApiService 在不同的服务里重名了好几次——Gateway 里有,Infrastructure 里有,甚至还有两个一模一样的 Feign 声明。单体化改造的时候做过一次重命名,这次微服务化又做了一次规范化:

  • 本地调用实现:Delegating + 接口名(如 DelegatingUserApiService
  • 远程调用实现:Remote + 接口名(如 RemoteUserApiService
  • Feign 接口声明:接口名 + FeignService(如 UserApiFeignService

这个命名约定很简单,但它的作用不小——你不用看代码实现,只看类名就知道它是干什么的。

第三步:调整依赖关系

改完之后,依赖关系变成这样:

1
2
3
4
5
6
7
8
9
单体架构:
xxx-manage-cirrus-service
→ sctelcp-delegating-api-starter (JVM 内部调用)
→ sctelcp-infrastructure-service-starter (公共能力)

微服务架构:
xxx-manage-service
→ sctelcp-user-api-feign-starter (远程调用)
→ sctelcp-infrastructure-service-starter (公共能力)

两个架构都依赖 sctelcp-infrastructure-service-starter,复用了底层能力。单体用 Delegating 做本地调用,微服务用 Feign 做远程调用。各自的装配由各自的 starter 处理,互不干扰。

一个未解决的疑问

写到这你可能会问:单体架构引入的 delegating-api-starter 依赖了 auth、user、gateway 等四个 service 级别的 starter,这不是会把整个服务都引入到单体里吗?

这确实是一个问题。delegating-api-starter 本质上是一个单体架构专用的聚合组件,它的存在就是为了在单体架构中把原本应该远程调用的接口变成本地调用。所以它引入这些 service 依赖是合理的——单体架构本来就需要这些 service。

但反过来说,如果一个团队确定只用微服务架构、永远不会用单体架构,那 delegating-api-starter 就是一个多余的模块。这也是为什么我们在项目分支中把它标记为”单体架构专用”——它不是微服务架构的依赖,微服务项目不需要它。

架构设计的完美状态是”每个模块只有一个清晰的职责”。但现实中的架构设计往往是”在给定的约束下找到局部最优解”。delegating-api-starter 就是为了解决”一套代码同时支持两种部署模式”这个约束而引入的中间层——它是手段,不是目的。

下一篇写什么

DelegatingService 讲完了。但还有一个东西在前面反复出现却一直没有展开——context-path。微服务为什么每个都要配 context-path?网关路由和它怎么配合?当 Spring Boot 3.x 废弃了 bootstrap.yml,配置文件应该怎么组织?spring.config.import 和 Nacos 共享配置怎么搭?

下一篇,把这些配置管理在迁移过程中的实际问题全部讲清楚。