Java-Feign+服务注册的多环境方案

微服务的开发模式下,联调和服务注册一旦涉及多个环境(开发/SIT/UAT),就会变得有些复杂。本文总结一下我们在此问题上尝试过的几个workaround,以及最终推荐的方案。

1. 背景

以下描述的案例中,将我们所拥有的服务精简为三个:

  • um:用户微服务
  • ent:企业微服务
  • bi:BI微服务
    ent会调用um;bi会调用ent和um。
    网络环境分成办公网段和开发环境网段。办公网段可以访问开发环境网段,但开发环境网段无法访问办公网段。
    三个微服务都被打包成镜像,以单副本Pod的形式部署在K8S云的开发环境节点上。
    服务注册使用Nacos,网关路由使用的是Zuul。

部署环境

2. 单环境内部请求流程

如果只考虑SIT环境,整个服务注册+请求的处理流程可以简单描述如下:

  1. um-sit服务(um的sit环境,下同)启动,将自己的service ip注册到Nacos服务端
  2. ent-sit服务启动,将自己的service ip注册到Nacos服务端
  3. 前端web对http://域名/api/ent-sit 的某个接口发起请求
  4. 通过K8S Ingress的域名映射,找到了Zuul应用
  5. Zuul向Nacos查询ent-sit的地址,得到ip:172.0.0.2。这个是ent-sit的service内部ip
  6. Zuul将请求转给ent-sit的service,Pod里的ent-sit容器中的应用接收到请求,开始处理
  7. ent-sit容器在处理过程中需要解析token,于是向Zuul请求um-sit
  8. Zuul向Nacos查询um-sit的地址,得到ip:172.0.0.1。这个是um-sit的service内部ip
  9. Zuul将请求转给um-sit。um处理完token,返回用户信息
  10. ent-sit处理结束,将结果返回给Zuul
  11. Zuul将结果转给前端web,流程结束

单环境内部请求流程

3. 遇到的问题

联调和测试过程中我们遇到了两个主要问题:

  • 联调会串服务
  • 无法和测试环境的服务联通

3.1 串服务

假设Alice和Bob都在开发ent,服务名都是ent-dev。于是Nacos记录了两个服务注册信息。
Cathy想和Alice联调。但如果Cathy配置调用的服务id也是ent-dev,请求就有一定几率会飘到Bob那里。那么很可能会发生Cathy的请求返回的结果不稳定,时对时错。

一种解决方案就是每个人在本地将自己的spring.application.name改为“服务名-姓名”,例如:ent-alice。

服务注册-workaround-1

但这个方案也存在问题:很容易在提交代码的时候误提交了自己的个人配置。Git里这个配置文件修改频繁,一看log就是服务名从Alice改为Bob,然后又被改会Alice。
如果只是这个问题,还有办法可以搞定,例如将服务名放到环境变量等。下一个问题是真正具有阻碍性的。

无法和测试环境的服务联通

在办公网段开发过程中,会发现无法调通SIT的微服务。
究其原因,这个是由于办公网段无法访问到service内部ip导致的。

服务注册-问题

办公网段ent-dev尝试调用um-sit服务的流程如下:

  • um-sit服务启动,将自己的service ip(172.0.0.1)注册到Nacos服务端
  • 本地的ent-dev启动,将自己的ip(10.0.0.1)注册到Nacos服务端
  • ent-dev通过name(um-sit)发起请求,向Nacos服务端查询um-sit的地址
  • Nacos服务端返回172.0.0.1
  • ent-dev尝试请求172.0.0.1,但由于这个地址是K8S的内部地址,外部无法访问,所以请求失败

一个解决方案就是每个人自己本地也起一个um的服务,假设服务名为um-alice。然后将请求的服务名也改为同样的服务名。
这样当发起请求时,Nacos会返回本机的地址,自然请求就可以成功了。
但这个解决方案除了和上个解决方案有同样的问题(容易误提交自己的个人配置)之外,也会导致每个人开发过程中都需要启动一堆依赖的微服务。姑且不说开发机的性能压力,也容易因为没有及时更新依赖服务的代码,导致联调出错。

4. 解决方案

4.1 解决方案一:在容器之外再部署一套微服务

既然service的内部ip地址无法被办公网段访问,那么另外以非容器方式在ECS上另外部署一套dev环境,就可以解决网络访问的问题。
但这个解决方案不完美:

  • 没有解决个人配置的问题
  • 在基于容器的持续集成方案之外,多维护了一套持续集成方案
  • 需要多部署一套环境,消耗硬件资源

4.2 【推荐】解决方案二:不使用name方式访问,使用域名/ip方式

需要同时避免串服务和个人配置这两个看起来互相冲突的问题,看起来只有放弃通过服务name方式调用,改为url调用。
通过Feign可以简化调用的代码。只要在@FeignClient的参数里配置了url,就会优先使用url。范例如下:

1
2
3
4
5
6
7
8
9
@FeignClient(name = "jsonPlaceHolderClient", url = "${feign-client.json-place-holder.url}"
, contextId = "JsonPlaceHolderClient")
public interface JsonPlaceHolderClient {
@GetMapping(value = "/posts")
List<Post> getPosts();

@GetMapping(value = "/posts/{postId}")
Post getPostById(@PathVariable("postId") Long postId);
}

application-dev.yml配置文件中,feign-client.json-place-holder.url可以默认填写为sit测试环境的地址。这样如果只是作为基础服务来调用(例如用户服务),就不需要在本地启动了。同样以办公网段ent-dev调用um-sit服务的流程作为范例:

  • um-sit服务启动,将自己的service ip(172.0.0.1)注册到Nacos服务端
  • 本地开发机的ent-dev启动
  • ent-dev通过域名http://域名/api/um-sit,对um的某个接口发起请求
  • Zuul收到请求,向Nacos查询um-sit的地址,得到ip:172.0.0.1。这个是um-sit的service内部ip
  • Zuul将请求转给um-sit的service,Pod里的um-sit容器中的应用接收到请求,开始处理
  • um-sit容器中的应用处理请求完毕,返回结果给Zuul
  • Zuul将结果转给本地开发机
    和K8S的Service不同,Ingress是可以被容器外访问到的,所以网络连通性上也没有任何问题。

如果是需要本机服务联调或与其他开发进行联调,只需要将url改为localhost或其他开发的ip即可。这样就等同于不涉及服务注册的直连。
提交的时候把这个临时改动revert回来,就不会将个人配置提交到代码仓库了。

5. Feign配置中的name和url

在最新版的Spring Cloud OpenFeign中,@FeignClientname属性是必需的。参见Spring Cloud OpenFeign

1
Previously, using the url attribute, did not require the name attribute. Using name is now required.

上文提到了如果同时配置了name和url,会优先使用url,而不是通过name访问服务。原理我们可以通过源代码来说明。这段是FeignClientFactoryBean的源代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* @param <T> the target type of the Feign client
* @return a {@link Feign} client created with the specified data and the context
* information
*/
<T> T getTarget() {
//FeignContext在FeignAutoConfiguration中自动注册,FeignContext用于客户端配置类独立注册
FeignContext context = this.applicationContext.getBean(FeignContext.class);
//创建Feign.Builder
Feign.Builder builder = feign(context);

//如果@FeignClient注解没有设置url参数
if (!StringUtils.hasText(this.url)) {
//url为@FeignClient注解的name参数
if (!this.name.startsWith("http")) {
this.url = "http://" + this.name;
}
else {
this.url = this.name;
}
//加上path
this.url += cleanPath();
//返回loadBalance客户端,也就是ribbon+eureka/Nacos的客户端
return (T) loadBalance(builder, context,
new HardCodedTarget<>(this.type, this.name, this.url));
}
//@FeignClient设置了url参数,不走服务注册的负载均衡
if (StringUtils.hasText(this.url) && !this.url.startsWith("http")) {
this.url = "http://" + this.url;
}
//加上path
String url = this.url + cleanPath();
//从FeignContext中获取client
Client client = getOptional(context, Client.class);
if (client != null) {
if (client instanceof LoadBalancerFeignClient) {
// not load balancing because we have a url,
// but ribbon is on the classpath, so unwrap
client = ((LoadBalancerFeignClient) client).getDelegate();
}
builder.client(client);
}
//从FeignContext中获取Targeter
Targeter targeter = get(context, Targeter.class);
//生成客户端代理
return (T) targeter.target(this, builder, context,
new HardCodedTarget<>(this.type, this.name, url));
}

从代码可以看到,只有没有设置url的情况下,才会通过loadBalance方法生成Ribbon的动态代理。
更多关于Spring Cloud OpenFeign的源代码分析,可以参见本文最后的参考资料。

6. 一些想法

实际调用过程中会发现第一次通过域名调用会较慢(2-3秒),但第二次就很快了。这是由于Zuul会通过SpringMVC对请求进行缓存。
但其实Zuul的路由功能Ingress本身已经实现得很好了。多引入一个Zuul会增加运维架构的复杂度,也会带来潜在的性能瓶颈。不过这个目前不在我们的控制范围。。。Zuul除了路由之外也可以做一些通用的token校验等,也并不是完全冗余,只是我们目前没有这么使用。

7. 参考资料

spring-cloud-openfeign原理分析 | 拍拍贷基础框架团队博客

本文永久链接 [ https://galaxyyao.github.io/2019/12/09/Java-Feign-服务注册的多环境方案/ ]