13.3 注册和发现服务

13.3 注册和发现服务

没有服务注册的话,Eureka服务注册中心没有任何用处。如果你的服务想要被其他服务发现和消费,就需要将它们作为服务注册中心的客户端。为了让应用(任何应用,但很可能是微服务)成为服务注册中心的客户端,我们至少需要将Eureka客户端依赖添加到服务应用的构建文件中:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>

与Eureka服务器starter依赖类似,我们还需要为Spring Cloud的依赖管理设置Spring Cloud的版本属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<properties>
...
<spring-cloud.version>Finchley.SR1</spring-cloud.version>
</properties>
...
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

我们可以手动添加这些条目到服务应用的pom.xml文件中,但是更简单的方式是在Spring Initializr的复选框中选中Eureka Discovery依赖。

Eureka client starter依赖会添加通过Eureka发现服务所需的所有内容,包括Eureka的客户端库以及Ribbon负载均衡器。我们只需要将这个依赖添加进来,就能将应用变成Eureka服务注册中心的客户端。当应用启动的时候,它会尝试联系在本地运行并且端口为8761的Eureka服务器,并将自身基于UNKNOWN名称进行注册。

13.3.1 配置Eureka客户端属性

对于开发阶段来说,默认位置的Eureka服务器是可以接受的,如果我们要将服务部署到localhost之外,就需要覆盖它的值。另外,默认的服务名为UNKNOWN,这是一个非常糟糕的选择……但是,坦白来讲,任何形式的默认方案都会很糟糕,因为如果采用默认方案,那么所有服务都会具有相同的名称。

更改服务在Eureka中的注册名称非常简单,我们只需要设置spring.application.name属性就可以了。例如,如果想要注册一个处理taco配料相关操作的服务,那么我们可以将其注册为ingredient-service。在application.yml中,将会如下所示:

1
2
3
spring:
application:
name: ingredient-service

设置完这个属性之后,我们就可以按照ingredient-service名称来查找服务了。另外,如果我们为配料服务添加多个实例,它们就会以相同的名称出现在注册中心,实际上,服务会扩展到多个实例,并假定它们是完全相同的,服务的消费者可以从中选择。此时,我们查看Eureka dashboard的话,服务将会如图13.5所示。

image-20211021153847278

图13.5 Eureka dashboard上的配料服务

在继续使用Spring Cloud的过程中,你会发现spring.application.name是我们要设置的最重要的属性之一。它决定了Eureka中的注册名。在第14章中我们将会看到,这个属性会帮助配置服务识别该应用,用来管理特定应用的配置。其他的Spring Cloud项目,如Spring Cloud Task(短暂存活的微服务)和Spring CloudSleuth(分布式跟踪),同样依赖spring.application.name属性来识别服务。

正如我们在第1章所学到的,默认情况下,所有的Spring MVC和Spring WebFlux应用都会监听8080端口。因为这些服务现在只会通过Eureka进行查找,所以它们监听什么端口也就无所谓了,Eureka能够知道它们使用的是什么端口。为了避免本地运行时潜在的端口冲突,我们可以将端口设置为0:

1
2
server:
port: 0
注意:将端口设置成0的话,应用会选择任意一个可用端口来启动。

现在,我们要考虑Eureka服务器的位置。默认情况下,Eureka客户端会假定Eureka服务器在本地运行(8761端口)。对于开发期来说,这种方式很不错,但是在生产环境中,大多数情况并非如此。因此,我们需要指定Eureka服务器的位置。这与Eureka服务器本身的实现方式完全相同,都是要使用eureka.client.service-url属性:

1
2
3
4
eureka:
client:
service-url:
defaultZone: http://eureka1.tacocloud.com:8761/eureka/

通过这样的配置,客户端会使用eureka1.tacocloud.com主机(端口8761)上的Eureka服务器进行注册。只要Eureka服务器在运行,这种方式就是没有问题的,但是一旦Eureka服务器因为某种原因而停机,服务注册就会失败。为了避免注册失败,最好是为服务配置两个或更多的Eureka实例:

1
2
3
4
5
eureka:
client:
service-url:
defaultZone: http://eureka1.tacocloud.com:8761/eureka/,
http://eureka2.tacocloud.com:8762/eureka/

当服务启动的时候,它会尝试使用zone中的第一个服务器进行注册。如果因为某种原因失败,它将会使用列表中的下一个服务器来进行注册。最终,如果出现故障的服务器重新恢复在线状态,它将会从对等的端上复制注册信息,这样就能将该服务的注册条目包含进来。

在Eureka中注册服务只完成整个任务的一半。服务在Eureka注册之后,其他的服务就可以发现和消费它们了。接下来,我们看一下如何消费Eureka中注册的服务。

13.3.2 消费服务

在消费者代码中,硬编码任何服务实例的URL都是错误的做法。这不仅会让消费者与服务的特定实例耦合在一起,而且一旦服务的主机和/或端口改变,消费者就会出问题。

对于消费者应用来说,在从Eureka中查找服务时,它要承担很多责任。Eureka可能会基于查找结果返回同一个服务的多个实例。如果消费者请求ingredient-service服务时得到了多个服务实例,那么它该如何选择正确的服务呢?

好消息是消费者应用根本不需要从中进行选择,甚至都不需要自己显式地进行服务查找。借助Spring Cloud的Eureka客户端支持和Ribbon客户端负载均衡器,我们可以很容易地查找、选择和消费服务实例。我们有两种方式可以消费从Eureka中查找到的服务:

  • 支持负载均衡的RestTemplate;
  • Feign生成的客户端接口。

选择哪种方式在很大程度上取决于个人喜好。下面我们将会看一下这两种方案(首先会介绍支持负载均衡的RestTemplate),然后你就可以从中选择最喜欢的方式了。

使用RestTemplate消费服务

你对Spring RestTemplate客户端的第一印象可能来源于第7章。我们快速回忆一下它的运行原理,在创建或注入RestTemplate之后,我们就可以发送HTTP调用并将响应绑定到领域类型上。例如,为了发送根据ID获取配料的HTTP GET请求,我们可以使用如下的RestTemplate代码:

1
2
3
4
public Ingredient getIngredientById(String ingredientId) {
return rest.getForObject("http://localhost:8080/ingredients/{id}",
Ingredient.class, ingredientId);
}

在这里,唯一的问题在于getForObject()的URL硬编码了特定的主机和端口。我想,你可能会将细节信息提取到一个属性中,但是如果我们将请求的目的地设置成配料服务众多实例中的某一个,那么我们所配置的URL会始终都指向同一个特定实例,这样就没有负载均衡器将请求分散到多个服务实例中了。

如果我们将应用变成Eureka客户端,就可以声明支持负载均衡的RestTemplatebean了。我们需要做的就是声明一个常规的RestTemplate bean,并为带有@Bean注解的方法再添加上@LoadBalanced

1
2
3
4
5
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}

@LoadBalanced注解有两个目的。首先,也是最重要的,它会告诉SpringCloud,这个RestTemplate要能够通过Ribbon来查找服务。其次,它会作为一个注入限定符(qualifier),所以有两个或更多RestTemplate bean的话,我们可以在注入的地方声明此处想要支持负载均衡的RestTemplate。

例如,就像上面的代码那样,我们想要使用支持负载均衡的RestTemplate来查找配料。首先,我们将支持负载均衡的RestTemplate注入需要它的bean中:

1
2
3
4
5
6
7
8
@Component
public class IngredientServiceClient {
private RestTemplate rest;
public IngredientServiceClient(@LoadBalanced RestTemplate rest) {
this.rest = rest;
}
...
}

随后,我们稍微修改一下getIngredientById()方法,使用服务的注册名,而不再明确使用主机和端口:

1
2
3
4
5
public Ingredient getIngredientById(String ingredientId) {
return rest.getForObject(
"http://ingredient-service/ingredients/{id}",
Ingredient.class, ingredientId);
}

发现区别了吗?getForObject()的URL不再使用特定的主机名或端口。在主机名和端口的位置上,我们使用了服务名ingredient-service。在内部, RestTemplate会要求Ribbon根据名称查找服务并从中选择一个实例。Ribbon非常乐于效力,它会将URL重写为选定服务实例的主机和端口,然后让RestTemplate像以往那样进行处理。

我们可以看到,使用支持负载均衡的RestTemplate与标准RestTemplate并没有太大的差异。关键的不同点在于客户端需要使用服务名,而不是显式的主机名和端口。如果你想使用WebClient来替代RestTemplate该怎么办呢?WebClient也能够和Ribbon组合使用根据名称来消费服务吗?

使用WebClient消费服务

在第11章中,我们看到WebClient提供了与RestTemplate类似的HTTP客户端,但是它使用的是像Flux和Mono这样的反应式类型。如果你曾经被反应式编程的bug所困扰,那么你可能倾向于直接使用WebClient,而不是使用RestTemplate。好消息是,我们可以按照与RestTemplate类似的方式将WebClient作为支持负载均衡的客户端。我们需要做的第一件事就是声明一个返回WebClient.Builder bean的方法,该方法要添加@LoadBalanced注解:

1
2
3
4
5
@Bean
@LoadBalanced
public WebClient.Builder webClientBuilder() {
return WebClient.builder();
}

在声明完WebClient.Builder之后,我们就可以将支持负载均衡的WebClient.Builder注入任何需要它的地方。例如,我们可以将它注入IngredientServiceClient的构造器中:

1
2
3
4
5
6
7
8
9
@Component
public class IngredientServiceClient {
private WebClient.Builder wcBuilder;
public IngredientServiceClient(
@LoadBalanced WebClient.Builder webclientBuilder wcBuilder) {
this.wcBuilder = wcBuilder;
}
...
}

最后,在我们需要使用它的时候,可以利用WebClient.Builder构建一个WebClient,然后就能够使用Eureka注册的服务名来发送请求了:

1
2
3
4
5
6
public Mono<Ingredient> getIngredientById(String ingredientId) {
return wcBuilder.build()
.get()
.uri("http://ingredient-service/ingredients/{id}", ingredientId)
.retrieve().bodyToMono(Ingredient.class);
}

与支持负载均衡的RestTemplate类似,在发送请求的时候,这里不需要明确指定主机和端口。系统会从给定的URL中抽取出服务名,通过这个名称在Eureka中查询服务。Ribbon会选择服务的一个实例,在真正发送请求之前,会根据所选实例的主机和端口重写URL。

这种编程模型非常容易掌握,若你已经熟悉RestTemplate或WebClient则更是如此。Spring Cloud还有一个技巧,接下来我们看一下如何使用Feign创建基于接口的服务客户端。

定义Feign客户端接口

Feign是一个REST客户端库,使用一种特殊的、接口驱动的方式来定义REST客户端。简而言之,如果你喜欢Spring Data自动实现repository接口的方式,那么你肯定会喜欢Feign的。

Feign最初是Netflix的一个项目,后来变成了独立的开源项目,名为OpenFeign。单词feign的意思是“伪装”,稍后我们将会看到对于假装成REST客户端的项目,这是一个很合适的名称。

要使用Feign,我们首先需要将依赖添加到项目的构建文件中。在pom.xml文件中,如下的<dependency>就可以完成该任务:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

在使用Spring Initializr的时候,我们可以通过选中Feign复选框自动添加该starter依赖。令人遗憾的是,目前不会根据已有的依赖启用自动配置功能。所以,我们需要将@EnableFeignClients添加到某个配置类上:

1
2
3
4
@Configuration
@EnableFeignClients
public RestClientConfiguration {
}

现在,到了有意思的部分。假设我们想要通过注册在Eureka中名为ingredient-service的服务获取一个Ingredient,需要做的就是定义如下的接口:

1
2
3
4
5
6
7
8
9
10
package tacos.ingredientclient.feign;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import tacos.ingredientclient.Ingredient;
@FeignClient("ingredient-service")
public interface IngredientClient {
@GetMapping("/ingredients/{id}")
Ingredient getIngredient(@PathVariable("id") String id);
}

这是一个很简单的接口,并没有实现类。在运行期,当Feign发现它的时候,这一切就都不重要了,Feign会自动创建一个实现类并将其暴露为Spring应用上下文中的bean。

仔细观察一下,我们会发现其中有一些注解在发挥作用,并将所有功能组合在了一起。接口上的@FeignClient注解会指定该接口上的所有方法都会对名为ingredient-service的服务发送请求。在内部,服务将会通过Ribbon进行查找,这与支持负载均衡的RestTemplate运行方式是一样的。

随后就是getIngredient()方法,它使用了@GetMapping注解。你会发现,这个注解来源于Spring MVC。确实,就是同一个注解。现在它用在了客户端,而不是用在控制器上。它表明,任何对getIngredient()的调用都会对“/ingredients/{id}”路径发起GET请求,其中的主机和端口是通过Ribbon选定的。@PathVariable注解同样来自Spring MVC,会将方法参数映射到给定路径的占位符上。

现在,我们需要做的就是将Feign实现的接口注入需要的地方并开始使用它。例如,要在控制器中使用它,我们可以这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Controller
@RequestMapping("/ingredients")
public class IngredientController {
private IngredientClient client;
@Autowired
public IngredientController(IngredientClient client) {
this.client = client;
}
@GetMapping("/{id}")
public String ingredientDetailPage(@PathVariable("id") String id,
Model model) {
model.addAttribute("ingredient", client.getIngredient(id));
return "ingredientDetail";
}
}

我不知道你的观点如何,但是我觉得这非常流畅!很难说我最喜欢哪种方式:支持负载均衡的RestTemplate、WebClient,还是具有魔力的Feign客户端接口。不管选择哪种方式,我们的REST客户端都能根据名称消费在Eureka注册的服务,避免硬编码特定的主机名和端口。

值得一提的是,Feign提供了自己的注解。@RequestLine和@Param非常类似于Spring MVC中的@RequestMapping和@PathVariable,但是它们的使用方式略有差异。能够在客户端使用我们已经非常熟悉的Spring MVC注解是非常棒的,而且它们很可能与我们在定义服务控制器时所使用的注解是一样的。