基于现代Java技术栈的微服务架构

英文原文

现代Java技术栈里我们已经有了JDK 11,Kotlin,Spring 5,Spring Boot 2以及Gradle 5,还有可以用于生产环境的kotlin DSL,Junit 5,以及一大堆SpringCloud的类库,它们可以用来进行服务发现,创建API网关,客户端负载均衡,实现熔断器模式,编写声明式HTTP客户端,分布式跟踪系统,所有的这些。当然,要创建一个微服务的架构的话,并不需要上面所有的组件——但是这个过程会很有趣!

Microservices in Java

简介

在这篇文章里,你会了解一个使用Java技术栈的微服务架构,主要的组件列表如下(下面所列的版本是截止文章目前发布所使用的):

micro-service

我们的项目包含5个微服务:3个基础服务(配置服务Config Server,服务发现Service discovery server,UI网关 UI gateway)以及用于示例的前端(Item UI)和后端(Item Service):


micro-service2

接下来会依次介绍上面的组件。在实际的项目中,要实现具体的业务逻辑,所使用的微服务会比这个多。但是,在这个架构上只需要加上和Item UI以及Item Service类似的组件就可以了。

声明

这篇文章没有将容器化和微服务编排考虑进来,因为目前这个项目里还没有用到它们。

配置服务Config Server

我们这里使用Spring Cloud Config来作为统一的配置中心。配置可以从多种不同的数据源进行读取,例如,一个单独的git仓库。在这个项目里,为了方便,我们把它们放在应用资源里:


microservice-3

Config server的配置(application.yml)如下:

yml
spring:
 profiles:
   active: native
 cloud:
   config:
     server:
       native:
         search-locations: classpath:/config
server:
 port: 8888

使用8888端口,可以让Config service客户端使用默认的配置,不需要在bootstrap.yml里指定端口。在启动的时候,客户端会用一个GET请求来通过Config server的HTTP API获取配置。

这个微服务应用本身的代码只有一个文件,它里面包含应用类(applicaiton class)的声明以及main方法,main方法和java代码有些不同,它是一个顶级函数:

@SpringBootApplication
@EnableConfigServer
class ConfigServerApplication
fun main(args: Array<String>) {
   runApplication<ConfigServerApplication>(*args)
}

其它微服务里的应用类(Application class)以及main方法都是类似的形式。

服务发现(Service Discover Service)

服务发现是一种微服务架构模式,它能隐藏应用之间的交互细节,让你不用关心应用实例的数量以及网络位置的变动。它的关键组件包含服务注册,微服务的存储,微服务实例以及网络位置(更多信息请参考这个)。

在这个项目里,服务发现是基于Netflix Eureka实现的,它是一个客户端服务发现:Eureka服务端会负责服务注册,客户端会请求Eureka服务端来获取应用实例列表,然后在向微服务发送请求之前通过Netflix Robbon来进行负载均衡。Netflix Eureka和很多其他Netflix OSS技术栈的其他组件(例如Hystrix和Ribbon)相似,都使用Spring Cloud Netflix来和Spring进行整合。

服务发现的配置文件,在资源文件里(bootstrap.yml),它只包含应用名以及标明在连接不上Config server的时候是否要中断服务启动的配置。

spring:
 application:
   name: eureka-server
 cloud:
   config:
     fail-fast: true

其他的配置都是在Config server的eureka-server.yml文件里进行配置:

server:
 port: 8761
eureka:
 client:
   register-with-eureka: true
   fetch-registry: false

Eureka服务用的8761端口,这样可以允许所有的Eureka客户端使用默认的配置。register-with-eureka这个参数是用来表示当前服务是不是也要注册到Eureka server上。fetch-registry参数表示Eureka客户端是否需要从服务端获取数据。

已注册的服务列表和其他的信息可以在http://localhost:8761/上查看:

image

其他可以用作服务发现的选项有Consul,Zookeeper等等。

Item Service

这是一个使用Spring 5里出现WebFlux框架来实现的一个后台系统,使用Kotlin DSL的代码如下:

@Bean
fun itemsRouter(handler: ItemHandler) = router {
   path("/items").nest {
       GET("/", handler::getAll)
       POST("/", handler::add)
       GET("/{id}", handler::getOne)
       PUT("/{id}", handler::update)
   }
}

HTTP请求都被代理到ItemHandler bean上。例如,获取一系列对象列表的实现类似于:

fun getAll(request: ServerRequest) = ServerResponse.ok()
       .contentType(APPLICATION_JSON_UTF8)
       .body(fromObject(itemRepository.findAll()))

因为有了spring-cloud-starter-netflix-eureka-client的依赖,这个应用就变成了Eureka的一个客户端,它会向Eureka注册中心发送和接受数据。注册完成之后,它会定时向Eurake服务发送心跳信息,如果在一段时间内Eureka服务端没有收到心跳,或者在一段时间内都到的心跳值低于某个阈值的话,Eureka服务端就会将这个应用实例从注册中心移除。

下面来看看给Eureka服务端发送消息的一种方式:

@PostConstruct
private fun addMetadata() = aim.registerAppMetadata(mapOf("description" to "Some description"))

你可以通过用Postman访问 http://localhost:8761/eureka/apps/items-service 来验证发给Eureka服务端的数据:

image

Items UI

这个微服务,除了会和UI gateway(下一节介绍)交互之外,它也是Item service的前端,它可以通过以下几种方式和Item service进行交互:

  1. 客户端到REST API, 通过OpenFeign实现
interface ItemsServiceFeignClient {
   @GetMapping("/items/{id}")
   fun getItem(@PathVariable("id") id: Long): String
   @GetMapping("/not-existing-path")
   fun testHystrixFallback(): String
   @Component
   class ItemsServiceFeignClientFallbackFactory : FallbackFactory<ItemsServiceFeignClient> {
       private val log = LoggerFactory.getLogger(this::class.java)
       override fun create(cause: Throwable) = object : ItemsServiceFeignClient {
           override fun getItem(id: Long): String {
               log.error("Cannot get item with id=$id")
               throw ItemsUiException(cause)
           }
           override fun testHystrixFallback(): String {
               log.error("This is expected error")
               return "{\"error\" : \"Some error\"}"
           }
       }
   }
}
  1. 通过RestTemplate bean来实现

在java-config里,创建一个bean:

@Bean
@LoadBalanced
fun restTemplate() = RestTemplate()

然后这样使用:

fun requestWithRestTemplate(id: Long): String =
       restTemplate.getForEntity("http://items-service/items/$id", String::class.java).body ?: "No result"
  1. WebClient bean(这个方式仅限于WebFlux框架)
    在java-config里,创建一个bean:
@Bean
fun webClient(loadBalancerClient: LoadBalancerClient) = WebClient.builder()
       .filter(LoadBalancerExchangeFilterFunction(loadBalancerClient))
       .build()

然后这样使用:

       webClient.get().uri("http://items-service/items/$id").retrieve().bodyToMono(String::class.java)

你可以通过http://localhost:8081/exmple来验证这三种方式返回的都是一样的结果:

  • 通过RestTemplate获取Item: {"id":1, "name": "first"}
  • 通过WebClient获取Item: {"id":1, "name": "first"}
  • 通过FeignClient获取Item: {"id":1, "name": "first"}

我个人倾向于使用OpenFeign,因为它可以部署一个被调用服务的协议,然后Spring会对它进行实现。这个实现可以像一个正常的bean一样来注入和使用:

itemsServiceFeignClient.getItem(1)

如果请求失败了,FallFactory会被调用进行错误处理,然后返回相应的相应信息(或者继续传播异常)。在请求连续失败的情况下,断路器(Circuit breaker)会进行断路,给宕机的服务以时间来进行恢复。

要使用Feign客户端的话,需要在application class上加上@EnableFeignClients注解:

@SpringBootApplication
@EnableFeignClients(clients = [ItemsServiceFeignClient::class])
class ItemsUiApplication

如果要在Feign客户端里使用Hystrix异常恢复机制的话,你需要添加以下配置:

feign:
 hystrix:
   enabled: true

你可以通过http://localhost:8081/hystrix-fallback这个路径来测试Hystrix的异常恢复机制。Feign客户端会请求Item service里不存在的一个路径,这样会导致如下的错误返回:

{"error" : "Some error"}

UI Gateway

API网关(API Gateway)模式可以帮助你讲所有其他微服务提供的API集中到一个节点上。实现这个模式的应用会将对应的请求路由到底层的系统上,并且它还会有一些额外的功能,例如身份验证。

在这个项目里,为了更加清楚地进行区别,实现了一个单独的UI Gateway,一个集成了所有UI的节点;很显然,API gateway也是类似的实现方式。这个微服务是基于Sping Cloud Gateway框架进行实现的。另外一个可选的方案是Netflix Zuul,它包含在Netflix OSS里,并且通过Spring Cloud Netflix和Spring Boot进行集成。

这个UI gateway使用443端口,使用生成的SSL证书(存放在项目里)。SSL和HTTPS的配置如下:

server:
 port: 443
 ssl:
   key-store: classpath:keystore.p12
   key-store-password: qwerty
   key-alias: test_key
   key-store-type: PKCS12

用户名和密码都存在基于WebFlux规范的ReactiveUserDetailsService里,它是一个基于Map的实现:

@Bean
fun reactiveUserDetailsService(): ReactiveUserDetailsService {
   val user = User.withDefaultPasswordEncoder()
           .username("john_doe").password("qwerty").roles("USER")
           .build()
   val admin = User.withDefaultPasswordEncoder()
           .username("admin").password("admin").roles("ADMIN")
           .build()
   return MapReactiveUserDetailsService(user, admin)
}

安全选项设置如下:

@Bean
fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain = http
       .formLogin().loginPage("/login")
       .and()
       .authorizeExchange()
       .pathMatchers("/login").permitAll()
       .pathMatchers("/static/**").permitAll()
       .pathMatchers("/favicon.ico").permitAll()
       .pathMatchers("/webjars/**").permitAll()
       .pathMatchers("/actuator/**").permitAll()
       .anyExchange().authenticated()
       .and()
       .csrf().disable()
       .build()

上面的配置表示部分资源(例如静态资源)是所有用户可以访问的,以及那些不需要鉴权的资源,最后其他的(.anyExchange())都只运行登陆用户访问。当你访问一个需要鉴权的资源时,你会被重定向到登陆界面(https://localhost/login):

microservice-5

这个界面通过Webjars来和我们的项目进行交互,你可以像正常客户端的库来依赖管理。Thymeleaf是用来生成HTML页面的。Login页面是通过WebFlux进行配置的:

@Bean
fun routes() = router {
   GET("/login") { ServerResponse.ok().contentType(MediaType.TEXT_HTML).render("login") }
}

Spring Cloud Gateway的路由可以通过YAML或者java config来进行配置。路由可以手动进行配置,也可以通过接收注册中心的数据来进行配置。如果需要路由的UI组件的数量比较大的话,通过注册中心集成来进行路由会方便很多。

spring:
 cloud:
   gateway:
     discovery:
       locator:
         enabled: true
         lower-case-service-id: true
         include-expression: serviceId.endsWith('-UI')
         url-expression: "'lb:http://'+serviceId"

include-expression的值表示serviceId以“-UI”结尾,url-expression表示通过HTTP访问的服务,这个和UI gateway使用HTTPS不同,客户端的负载均衡(这里使用Netflix Ribbon)会被使用。

接下来,我们看一个使用Java config手动配置的一个实现(没有和注册中心集成):

@Bean
fun routeLocator(builder: RouteLocatorBuilder) = builder.routes {
   route("eureka-gui") {
       path("/eureka")
       filters {
           rewritePath("/eureka", "/")
       }
       uri("lb:http://eureka-server")
   }
   route("eureka-internals") {
       path("/eureka/**")
       uri("lb:http://eureka-server")
   }
}

第一个路由指向之前所展示的Eureka服务的主页(http://localhost:8761),第二条路用来加载当前页面的资源。

应用创建的路由都可以通过访问https://localhost/actuator/gateway/routes这个地址来查看。

在底层的微服务中,可能需要用户在UI gateway里的账号或者角色。为了实现这个,我添加了一个过滤器(Filter)来往请求头里添加相应的信息:

@Component
class AddCredentialsGlobalFilter : GlobalFilter {
   private val loggedInUserHeader = "logged-in-user"
   private val loggedInUserRolesHeader = "logged-in-user-roles"
   override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain) = exchange.getPrincipal<Principal>()
           .flatMap {
               val request = exchange.request.mutate()
                       .header(loggedInUserHeader, it.name)
                       .header(loggedInUserRolesHeader, (it as Authentication).authorities?.joinToString(";") ?: "")
                       .build()
               chain.filter(exchange.mutate().request(request).build())
           }
}

现在,让我们通过UI gateway来访问Item UI — https://localhost/items-ui/greeting - 立马能够验证Item UI已经正确地处理了请求头的信息:

microservice-6

Spring Cloud Sleuth是一个用来在分布式系统里追踪请求的一个解决方案。Trace Id(通过identifier进行传递)以及Span Id(用来区分一个事务单位)都被加入到跨多个微服务的请求里(为了便于理解,我简化了整个流程,详细的信息可以参考这里):

image

只需要添加spring-cloud-starter-sleuth的依赖,这个功能就可以使用了。

通过添加合适的日志配置,你就可以在控制台里看到微服务相关的信息(Trace Id和Span Id都展示在微服务名称之后):

DEBUG [ui-gateway,009b085bfab5d0f2,009b085bfab5d0f2,false] o.s.c.g.h.RoutePredicateHandlerMapping   : Route matched: CompositeDiscoveryClient_ITEMS-UI
DEBUG [items-ui,009b085bfab5d0f2,947bff0ce8d184f4,false] o.s.w.r.function.server.RouterFunctions  : Predicate "(GET && /example)" matches against "GET /example"
DEBUG [items-service,009b085bfab5d0f2,dd3fa674cd994b01,false] o.s.w.r.function.server.RouterFunctions  : Predicate "(GET && /{id})" matches against "GET /1"

如果你想展示调用关系的图状信息的话,你可以使用Zapkin,它会执行服务请求,然后聚合微服务HTTP请求头里的信息。

构建

取决于你的操作系统,使用gradlew clean build或者./gradlew clean build

如果使用Gradle wrapper,就没有必要安装Gradle了。

在JDK 11.0.1上,能够正常构建并按顺序启动。除此之外,这个项目在JDK 10上面也是可以工作的,所以我保证这个版本上运行也没有问题。但是对于更早的版本JDK,我没有任何数据支撑。另外需要考虑的一点就是,这里使用的Gradle 5支持只支持JDK 8及以后的版本。

发布

我建议按照本文介绍的顺序来启动应用。如果你使用Intellij IDEA,并且有Run Dashboard的话,你会看到类似下图的界面:


microservice-launch

结论

这篇文章里我们介绍了业内建议的基于现代Java技术栈的微服务架构的一个实例,包含主要的组件以及一些特性。希望这篇文章对您有所帮助。谢谢!

参考

Github上的项目代码
Chris Richardson的微服务相关的文章
Martin Fowler的微服务相关的文章
Martin Fowler的微服务的指南

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345

推荐阅读更多精彩内容