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所示。
图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:
注意:将端口设置成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注解是非常棒的,而且它们很可能与我们在定义服务控制器时所使用的注解是一样的。