购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

2.3 抽取代码为一个独立的微服务

以库的方式共享代码是一个好的开端,不过,正如我们在2.2.1节中所介绍的,它也有不足,包含多个问题。例如,使用库的开发者需要考虑兼容性及相关的问题。他们将无法自由地使用第三方库。与此同时,导入库的代码意味着你的代码与库代码之间存在着依赖上的紧耦合。这并不是说微服务架构就没有紧耦合,它可能也存在各种耦合,譬如在API级别、以请求格式等进行耦合。采用库与微服务架构,在耦合这一点上,二者的区别主要在于发生的场所。

如果耦合的代码逻辑可以抽取为独立的业务领域,我们就可以创建一个新的微服务,以HTTP API的方式提供相关功能。譬如,我们可以将之前抽取出来、单独提供的功能定义为一个新的业务领域。之前讨论的授权组件是说明这一问题的好例子,它提供的令牌验证功能相对独立,有自己的业务领域。我们可以找到新服务能处理的业务实体,譬如授权服务可以处理user实体的用户名和密码。

注意 我们对例子做了高度的简化,然而现实中,授权服务通常需要访问其他系统的信息(譬如,数据库中的信息)。如果权限信息存储在数据库中,那么将授权逻辑抽取为一个独立的微服务就更合理了。出于简化的目的,在我们设计的例子中,授权服务并没有对外部服务进行访问。

添加新的服务会带来一系列不可忽略的开销。这些开销并不局限于开发,还有进行维护所需的人力。很明显,授权服务有自己的业务领域,有独立的业务模型。因此,授权服务与现有平台是独立的。无论是Person服务还是Payment服务都与授权服务没有太大的关系。基于这些考量,我们看看如何实现授权服务。图2.6展示了这3个服务之间的关系。

图2.6 授权服务与Person服务和Payment服务之间的关系

如图2.6所示,新的架构由3个独立的服务组成,服务之间使用HTTP API通信连接。这意味着无论是Person服务还是Payment服务,都需要多执行一次HTTP调用请求,对它们的令牌进行验证。如果你的应用对高性能没有要求,多进行一次HTTP调用请求应该不会有什么大问题(我们假设该请求发生于集群内部,或者一个封闭网络内,请求的服务器并非随机选择的某个服务器)。

在新架构中,之前重复的或者以库方式提取出的授权逻辑会被抽象为授权服务,以HTTP API的方式,经由/auth端点提供访问。我们的客户端会向授权服务发送验证令牌的请求,如果验证失败,授权服务会返回值为401的HTTP返回码。如果令牌验证通过,HTTP API会返回200 OK的状态码。代码清单2.4展示了如何构建新的验证服务。

代码清单2.4 使用HTTP端点提供的授权服务
@Path("/auth")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class AuthResource {
 
  private final AuthService authService = new AuthService();
  
  @GET
  @Path("/validate/{token}")
  public Response getAllPayments(@PathParam("token") String token) {
    if (authService.isTokenValid(token)) {
      return Response.ok().build();
    } else {
      return Response.status(Status.UNAUTHORIZED).build();
    }
  }
}

由于AuthService已经封装了令牌验证的逻辑,授权的执行将通过HTTP请求的方式实现,不再使用库函数调用的方式。授权的代码将存放在单独的授权微服务库中。Payment和Person服务不再需要以直接导入授权库,或者在自己的代码库中实现授权逻辑的方式来执行授权相关的操作,现在只需要使用一个HTTP客户端向/auth端点发送HTTP请求即可完成验证令牌的工作。代码清单2.5展示了发送HTTP请求的逻辑。

代码清单2.5 向授权服务发送HTTP请求
// 向独立的服务发送请求
public boolean isTokenValid(String token) throws IOException {
  CloseableHttpClient client = HttpClients.createDefault(); 
  HttpGet httpGet = new HttpGet("http://auth-service/auth/validate/" + 
   token);
  CloseableHttpResponse response = client.execute(httpGet);  ◁--- 向独立的授权服务发送HTTP请求
  return response.getStatusLine().getStatusCode() == HttpStatus.SC_OK;
 }

在代码清单2.5中,我们创建了一个HTTP客户端执行HTTP请求。在实际生产系统中,客户端会通过在调用组件之间共享以减少打开的连接数来节约资源。

HTTP客户端发起一次HTTP GET请求,验证令牌的合法性。如果返回的状态码是OK,就意味着令牌是合法的。否则,令牌就是非法的。

注意 授权服务既可以使用auth-service的域名系统(domain name system,DNS)向外提供,也可以使用别的服务发现机制,譬如Eureka、Consul等。auth-service也可以使用静态IP地址的方式直接暴露给外部服务。

2.3.1 采用独立微服务方式的取舍与弊端

独立微服务方式解决了采用抽取通用代码到单独的库时所出现的部分问题。使用这部分代码的团队在采用单独库的方式时,他们的心态是不一样的。在你的代码库中导入一个库,该库就成为你的代码库的一部分,你要对它们负责。对比起来,采用库的方式的耦合度要比采用独立微服务方式的高得多。

与其他微服务集成时,我们就不需要考虑这么多,直接将它们当成黑盒即可。使用这种方式时唯一的集成点就是API,这些API既可以基于HTTP,也可以基于其他的协议。理论上,库的集成完全可以用类似的方法处理。然而,正如我们在2.2节所介绍的,在实际生产中,由于库在代码层面引入的依赖,我们不能将其作为黑盒对待。

调用微服务通常意味着你需要客户端库的支持,这些库会执行具体的调用逻辑,而这又会增加新的依赖。理论上,你可能再次落入前文介绍的依赖传递陷阱中。不过,在实际项目中,大多数微服务为了调用其他服务,应该都已经使用了某个客户端库。这些库可能是基于HTTP的客户端或者是基于其他协议的客户端。因此,当你需要在你的服务中执行微服务调用时,可能使用同样的HTTP客户端就可以了。如此一来,由每个被调用服务引入的额外依赖问题就迎刃而解了。

假设我们的授权服务是个独立的微服务,向外提供对应的API。我们已经知道这个方式能解决库集成方式的一些问题。然而,事物都有两面性,维护一个独立的微服务的开销是巨大的。采用这种方式,我们要做的就不仅局限于编写授权服务的代码了,还需要做很多其他的事情。

独立的微服务意味着你需要创建将代码部署到云端或者私有数据中心基础架构上的部署流程。采用库的集成方式也需要部署流程,不过它的部署流程简单、直观得多。你只需要将代码打包成一个JAR文件,将其部署到某个存储库管理器即可。而使用微服务,你还需要有人或者某种机制监控服务的健康状态,一旦发生故障或者出现错误就需要做相应的处理。注意,创建部署、维护、监控的流程等是重要的前置开销(库集成方式也有类似的前置开销)。一旦这些流程完成,后续微服务的开发要简单得多。我们接下来更深入地讲解采用独立微服务方式时都有哪些重要的因素要考虑。

部署流程

微服务会作为一个独立的进程部署和运行。这意味着这样的进程需要进行监控,出现问题或者发生失效时,需要团队的人跟进和处理。因此,创建、监控、警告都是你创建独立的微服务时需要考虑的因素。如果你的公司有一整套的微服务生态,很可能已经有现成的警告和监控方案了。如果你是公司里希望采用这种架构的第一批人之一,很可能你需要从头搭建整套解决方案。这意味着需要较高的集成开销,以及大量的额外工作。

版本

微服务的版本管理在某些方面比库的版本管理要容易得多。你的库应该遵守版本语义,大版本应该尽量保持API的兼容性。微服务的API版本也应遵守同样的准则,保持后向的兼容性。在实际项目中,监控端点的使用情况要容易得多,如果发现某些端点不再使用,即可快速地决定将这些端点弃用。如果你正在开发一个库,需要注意尽量保持其后向的兼容性,否则会导致旧版本的库无法平滑升级到新版本。破坏后向兼容性意味着升级库为新版本后,客户端无法成功编译。这样的变更是不可接受的。

如果你使用的是HTTP API的集成方式,可以通过一个简单的计数器,使用Dropwizard这样的指标库统计各个端点的使用情况。如果某个端点对应的计数器很长时间都保持不变,并且其服务仅为公司内部服务,你就可以考虑弃用这一端点了。如果端点提供的服务是开放给所有人的,并且提供了相关的文档,弃用这样的端点时需要慎重一些,你可能需要尽可能长久地支持它们。即便是收集的指标数据表明这些端点使用得比较少,甚至很长时间都没有人调用,也不能弃用这些端点。只要有公开的文档,就可能有某些用户准备使用它们。

至此,相信你已经了解了采用微服务方式能为API演进带来更高的灵活性。我们会在第12章更深入地介绍兼容性相关的内容。

资源消耗

采用库的方式时,客户端代码会消耗更多的计算资源。Payment服务处理的每一个请求,都需要由代码进行令牌验证。根据具体的情况,如果这部分代码的资源消耗比较大,你需要增加CPU或者内存资源。

如果验证逻辑由独立的服务提供的API进行处理,客户端就完全不用考虑扩展性以及这部分的资源消耗。处理会在某个微服务实例上执行。如果处理的请求过多,负责相应服务的团队有义务做对应的调整,适当增加该服务实例的数量。

需要注意的是,采用微服务方式的客户端代码会有额外的HTTP请求,因为每次验证都需要与微服务做一次应答。如果要封装在微服务API内的逻辑很简单,可能最后花费在HTTP调用上的开销就已经远超直接在客户端执行该逻辑的开销。如果这部分逻辑比较复杂,那么HTTP通信的开销与微服务计算的开销相比就可以忽略不计。决定是否要抽取某部分逻辑时,以上的利弊也是你应该考虑的。

性能

最后,你需要衡量执行额外的HTTP请求对性能的影响。用于授权的令牌通常都有对应的过期时间。因此,你可以将它们进行缓存,减少服务需要处理的请求数量。为了实现缓存功能,你需要在客户端代码中引入缓存库。

在实际项目中,这两种方式(库和外部微服务)都是常见的提供业务功能的途径。将某部分业务逻辑抽取到独立的微服务中,每个用户请求都需要执行额外的HTTP请求,这可能是要着重斟酌的不足。你需要衡量采用这样的设计会对你的服务的响应延迟以及对SLA产生什么样的影响。图2.7展示了一个这样的场景。

图2.7 增加额外的延迟会影响你的服务

举个例子,如果根据你的SLA要求,99%的请求延迟需要小于 n ms,增加对其他服务的调用后,之前定义的SLA要求很可能无法满足。如果微服务99%的请求延迟小于 n ms,而你希望通过并发、重试或者推测执行(speculative execution)提升服务的处理能力,这可能会让情况变糟,因为微服务处理后续99%的请求时延迟可能超过 n ms。出现这种情况时,就无法满足你的SLA要求了。这时,你可能需要与相关干系人协调,增大SLA要求中延迟的范围。如果这不可行,你就需要花更多的时间研究如何降低后续99%的请求的延迟,或者使用切换到抽取库的方式。

即便你没有严苛的延迟要求,也要特别留意微服务是否存在连锁故障,并为服务依赖的微服务出现临时无法访问的情况准备预案。连锁故障问题并不是微服务引入的新问题,它在任何有外部系统依赖的场景(譬如数据库、认证API等)中都可能发生。

如果业务流程需要新增一个外部请求,你需要考虑相应外部服务无法访问时应该如何处理。你可以按照指数退避(exponential backoff)的策略进行重试,给下游服务一定的恢复时间,避免用大量请求压垮服务。采用这一策略时,你可以每隔 x ms探测一次下游服务的状态,如果下游服务已恢复,则可以逐步增加流量。使用指数退避策略时,你的重试操作应该以递减的频率进行。譬如,1 s之后执行第一次重试,10 s之后执行第二次重试,30 s之后执行第三次重试,以此类推。如果使用指数退避策略一段时间(重试一定次数)后,服务依旧没有恢复,你需要使用熔断模式(circuit breaker)规避失效无限向后扩展。

你需要提供下游服务失效时的应急机制。举个例子,你有一个支付系统,如果支付服务失效了,你可能会决定确认支付,并在下游服务恢复之后再从账户中扣款。这种解决方案的实施需要万分慎重,它必须是一个经过缜密考量之后的业务决定。

可维护性

如你所见,决定创建独立的微服务时,我们要考虑大量的取舍。实际项目中,采用这种方式需要更多的计划、更大的维护开销。使用之前,最好将其与共享库的方式做充分的比较,列出各自的优缺点,通常共享库的方式更简单直接。如果需要共享的逻辑比较简单,没有大量的依赖,采用共享库的方式是比较推荐的做法。另外,如果共享的逻辑比较复杂,并且可以单独抽取出来作为一个独立的业务组件,你可以考虑创建一个新的微服务。后一种方式的工作量是比较大的,很可能要一个专门团队来支持。

2.3.2 关于独立微服务的总结

回顾采用微服务方式的各种取舍,我们可以知道,它有很多的缺点。你需要实现大量新的组件。即便你完美地实现了所有部分,依然无法避免通过不可靠的网络执行外部调用时会遭遇请求失败。选择到底采用共享库还是微服务方式进行集成时,你应该充分考虑其所有优缺点。

注意 如果某个功能可以被抽取为独立的服务或者共享库,将其外包出去也更加容易。譬如,我们可以将认证逻辑的实现外包给外部供应商。不过,采用这种方式也有诸多的不足,包括高昂的开发费用、内外部团队不易协调、变更变得愈加困难等。

在2.4节中,我们会从底层分析代码重复,会看到松耦合的价值。 NP2GTIeTNQsBl8y/4C+PvLYurzj+VRTWMSTRPj9aHnoJLYB/fwb53r7F/YsYhCZY

点击中间区域
呼出菜单
上一章
目录
下一章
×