现代Java技术栈里我们已经有了JDK 11,Kotlin,Spring 5,Spring Boot 2以及Gradle 5,还有可以用于生产环境的kotlin DSL,Junit 5,以及一大堆SpringCloud的类库,它们可以用来进行服务发现,创建API网关,客户端负载均衡,实现熔断器模式,编写声明式HTTP客户端,分布式跟踪系统,所有的这些。当然,要创建一个微服务的架构的话,并不需要上面所有的组件——但是这个过程会很有趣!
简介
在这篇文章里,你会了解一个使用Java技术栈的微服务架构,主要的组件列表如下(下面所列的版本是截止文章目前发布所使用的):
我们的项目包含5个微服务:3个基础服务(配置服务Config Server,服务发现Service discovery server,UI网关 UI gateway)以及用于示例的前端(Item UI)和后端(Item Service):
接下来会依次介绍上面的组件。在实际的项目中,要实现具体的业务逻辑,所使用的微服务会比这个多。但是,在这个架构上只需要加上和Item UI以及Item Service类似的组件就可以了。
声明
这篇文章没有将容器化和微服务编排考虑进来,因为目前这个项目里还没有用到它们。
配置服务Config Server
我们这里使用Spring Cloud Config来作为统一的配置中心。配置可以从多种不同的数据源进行读取,例如,一个单独的git仓库。在这个项目里,为了方便,我们把它们放在应用资源里:
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/
上查看:
其他可以用作服务发现的选项有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服务端的数据:
Items UI
这个微服务,除了会和UI gateway(下一节介绍)交互之外,它也是Item service的前端,它可以通过以下几种方式和Item service进行交互:
- 客户端到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\"}"
}
}
}
}
- 通过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"
- 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):
这个界面通过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已经正确地处理了请求头的信息:
Spring Cloud Sleuth是一个用来在分布式系统里追踪请求的一个解决方案。Trace Id(通过identifier进行传递)以及Span Id(用来区分一个事务单位)都被加入到跨多个微服务的请求里(为了便于理解,我简化了整个流程,详细的信息可以参考这里):
只需要添加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的话,你会看到类似下图的界面:
结论
这篇文章里我们介绍了业内建议的基于现代Java技术栈的微服务架构的一个实例,包含主要的组件以及一些特性。希望这篇文章对您有所帮助。谢谢!
参考
Github上的项目代码
Chris Richardson的微服务相关的文章
Martin Fowler的微服务相关的文章
Martin Fowler的微服务的指南