現(xiàn)代Java技術(shù)棧里我們已經(jīng)有了JDK 11,Kotlin壤追,Spring 5撩轰,Spring Boot 2以及Gradle 5坟乾,還有可以用于生產(chǎn)環(huán)境的kotlin DSL档桃,Junit 5枪孩,以及一大堆SpringCloud的類庫(kù),它們可以用來進(jìn)行服務(wù)發(fā)現(xiàn)藻肄,創(chuàng)建API網(wǎng)關(guān)蔑舞,客戶端負(fù)載均衡,實(shí)現(xiàn)熔斷器模式嘹屯,編寫聲明式HTTP客戶端攻询,分布式跟蹤系統(tǒng),所有的這些抚垄。當(dāng)然,要?jiǎng)?chuàng)建一個(gè)微服務(wù)的架構(gòu)的話谋逻,并不需要上面所有的組件——但是這個(gè)過程會(huì)很有趣呆馁!
簡(jiǎn)介
在這篇文章里,你會(huì)了解一個(gè)使用Java技術(shù)棧的微服務(wù)架構(gòu)毁兆,主要的組件列表如下(下面所列的版本是截止文章目前發(fā)布所使用的):
我們的項(xiàng)目包含5個(gè)微服務(wù):3個(gè)基礎(chǔ)服務(wù)(配置服務(wù)Config Server浙滤,服務(wù)發(fā)現(xiàn)Service discovery server,UI網(wǎng)關(guān) UI gateway)以及用于示例的前端(Item UI)和后端(Item Service):
接下來會(huì)依次介紹上面的組件气堕。在實(shí)際的項(xiàng)目中纺腊,要實(shí)現(xiàn)具體的業(yè)務(wù)邏輯畔咧,所使用的微服務(wù)會(huì)比這個(gè)多。但是揖膜,在這個(gè)架構(gòu)上只需要加上和Item UI以及Item Service類似的組件就可以了誓沸。
聲明
這篇文章沒有將容器化和微服務(wù)編排考慮進(jìn)來,因?yàn)槟壳斑@個(gè)項(xiàng)目里還沒有用到它們壹粟。
配置服務(wù)Config Server
我們這里使用Spring Cloud Config來作為統(tǒng)一的配置中心拜隧。配置可以從多種不同的數(shù)據(jù)源進(jìn)行讀取,例如趁仙,一個(gè)單獨(dú)的git倉(cāng)庫(kù)洪添。在這個(gè)項(xiàng)目里,為了方便雀费,我們把它們放在應(yīng)用資源里:
Config server的配置(application.yml)如下:
yml
spring:
profiles:
active: native
cloud:
config:
server:
native:
search-locations: classpath:/config
server:
port: 8888
使用8888端口干奢,可以讓Config service客戶端使用默認(rèn)的配置,不需要在bootstrap.yml里指定端口盏袄。在啟動(dòng)的時(shí)候忿峻,客戶端會(huì)用一個(gè)GET請(qǐng)求來通過Config server的HTTP API獲取配置。
這個(gè)微服務(wù)應(yīng)用本身的代碼只有一個(gè)文件貌矿,它里面包含應(yīng)用類(applicaiton class)的聲明以及main方法炭菌,main方法和java代碼有些不同,它是一個(gè)頂級(jí)函數(shù):
@SpringBootApplication
@EnableConfigServer
class ConfigServerApplication
fun main(args: Array<String>) {
runApplication<ConfigServerApplication>(*args)
}
其它微服務(wù)里的應(yīng)用類(Application class)以及main方法都是類似的形式逛漫。
服務(wù)發(fā)現(xiàn)(Service Discover Service)
服務(wù)發(fā)現(xiàn)是一種微服務(wù)架構(gòu)模式黑低,它能隱藏應(yīng)用之間的交互細(xì)節(jié),讓你不用關(guān)心應(yīng)用實(shí)例的數(shù)量以及網(wǎng)絡(luò)位置的變動(dòng)酌毡。它的關(guān)鍵組件包含服務(wù)注冊(cè)克握,微服務(wù)的存儲(chǔ),微服務(wù)實(shí)例以及網(wǎng)絡(luò)位置(更多信息請(qǐng)參考這個(gè))枷踏。
在這個(gè)項(xiàng)目里菩暗,服務(wù)發(fā)現(xiàn)是基于Netflix Eureka實(shí)現(xiàn)的,它是一個(gè)客戶端服務(wù)發(fā)現(xiàn):Eureka服務(wù)端會(huì)負(fù)責(zé)服務(wù)注冊(cè)旭蠕,客戶端會(huì)請(qǐng)求Eureka服務(wù)端來獲取應(yīng)用實(shí)例列表停团,然后在向微服務(wù)發(fā)送請(qǐng)求之前通過Netflix Robbon來進(jìn)行負(fù)載均衡。Netflix Eureka和很多其他Netflix OSS技術(shù)棧的其他組件(例如Hystrix和Ribbon)相似掏熬,都使用Spring Cloud Netflix來和Spring進(jìn)行整合佑稠。
服務(wù)發(fā)現(xiàn)的配置文件,在資源文件里(bootstrap.yml)旗芬,它只包含應(yīng)用名以及標(biāo)明在連接不上Config server的時(shí)候是否要中斷服務(wù)啟動(dòng)的配置舌胶。
spring:
application:
name: eureka-server
cloud:
config:
fail-fast: true
其他的配置都是在Config server的eureka-server.yml文件里進(jìn)行配置:
server:
port: 8761
eureka:
client:
register-with-eureka: true
fetch-registry: false
Eureka服務(wù)用的8761端口,這樣可以允許所有的Eureka客戶端使用默認(rèn)的配置疮丛。register-with-eureka
這個(gè)參數(shù)是用來表示當(dāng)前服務(wù)是不是也要注冊(cè)到Eureka server上幔嫂。fetch-registry參數(shù)表示Eureka客戶端是否需要從服務(wù)端獲取數(shù)據(jù)辆它。
已注冊(cè)的服務(wù)列表和其他的信息可以在http://localhost:8761/
上查看:
其他可以用作服務(wù)發(fā)現(xiàn)的選項(xiàng)有Consul,Zookeeper等等履恩。
Item Service
這是一個(gè)使用Spring 5里出現(xiàn)WebFlux框架來實(shí)現(xiàn)的一個(gè)后臺(tái)系統(tǒng)锰茉,使用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請(qǐng)求都被代理到ItemHandler bean上。例如似袁,獲取一系列對(duì)象列表的實(shí)現(xiàn)類似于:
fun getAll(request: ServerRequest) = ServerResponse.ok()
.contentType(APPLICATION_JSON_UTF8)
.body(fromObject(itemRepository.findAll()))
因?yàn)橛辛?code>spring-cloud-starter-netflix-eureka-client的依賴洞辣,這個(gè)應(yīng)用就變成了Eureka的一個(gè)客戶端,它會(huì)向Eureka注冊(cè)中心發(fā)送和接受數(shù)據(jù)昙衅。注冊(cè)完成之后扬霜,它會(huì)定時(shí)向Eurake服務(wù)發(fā)送心跳信息,如果在一段時(shí)間內(nèi)Eureka服務(wù)端沒有收到心跳而涉,或者在一段時(shí)間內(nèi)都到的心跳值低于某個(gè)閾值的話著瓶,Eureka服務(wù)端就會(huì)將這個(gè)應(yīng)用實(shí)例從注冊(cè)中心移除。
下面來看看給Eureka服務(wù)端發(fā)送消息的一種方式:
@PostConstruct
private fun addMetadata() = aim.registerAppMetadata(mapOf("description" to "Some description"))
你可以通過用Postman訪問 http://localhost:8761/eureka/apps/items-service 來驗(yàn)證發(fā)給Eureka服務(wù)端的數(shù)據(jù):
Items UI
這個(gè)微服務(wù)啼县,除了會(huì)和UI gateway(下一節(jié)介紹)交互之外材原,它也是Item service的前端,它可以通過以下幾種方式和Item service進(jìn)行交互:
- 客戶端到REST API季眷, 通過OpenFeign實(shí)現(xiàn)
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來實(shí)現(xiàn)
在java-config里余蟹,創(chuàng)建一個(gè)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(這個(gè)方式僅限于WebFlux框架)
在java-config里,創(chuàng)建一個(gè)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來驗(yàn)證這三種方式返回的都是一樣的結(jié)果:
- 通過RestTemplate獲取Item: {"id":1, "name": "first"}
- 通過WebClient獲取Item: {"id":1, "name": "first"}
- 通過FeignClient獲取Item: {"id":1, "name": "first"}
我個(gè)人傾向于使用OpenFeign子刮,因?yàn)樗梢圆渴鹨粋€(gè)被調(diào)用服務(wù)的協(xié)議威酒,然后Spring會(huì)對(duì)它進(jìn)行實(shí)現(xiàn)。這個(gè)實(shí)現(xiàn)可以像一個(gè)正常的bean一樣來注入和使用:
itemsServiceFeignClient.getItem(1)
如果請(qǐng)求失敗了挺峡,F(xiàn)allFactory會(huì)被調(diào)用進(jìn)行錯(cuò)誤處理葵孤,然后返回相應(yīng)的相應(yīng)信息(或者繼續(xù)傳播異常)。在請(qǐng)求連續(xù)失敗的情況下橱赠,斷路器(Circuit breaker)會(huì)進(jìn)行斷路尤仍,給宕機(jī)的服務(wù)以時(shí)間來進(jìn)行恢復(fù)。
要使用Feign客戶端的話狭姨,需要在application class上加上@EnableFeignClients
注解:
@SpringBootApplication
@EnableFeignClients(clients = [ItemsServiceFeignClient::class])
class ItemsUiApplication
如果要在Feign客戶端里使用Hystrix異吃桌玻恢復(fù)機(jī)制的話,你需要添加以下配置:
feign:
hystrix:
enabled: true
你可以通過http://localhost:8081/hystrix-fallback
這個(gè)路徑來測(cè)試Hystrix的異潮模恢復(fù)機(jī)制赡模。Feign客戶端會(huì)請(qǐng)求Item service里不存在的一個(gè)路徑,這樣會(huì)導(dǎo)致如下的錯(cuò)誤返回:
{"error" : "Some error"}
UI Gateway
API網(wǎng)關(guān)(API Gateway)模式可以幫助你講所有其他微服務(wù)提供的API集中到一個(gè)節(jié)點(diǎn)上惕耕。實(shí)現(xiàn)這個(gè)模式的應(yīng)用會(huì)將對(duì)應(yīng)的請(qǐng)求路由到底層的系統(tǒng)上纺裁,并且它還會(huì)有一些額外的功能诫肠,例如身份驗(yàn)證司澎。
在這個(gè)項(xiàng)目里欺缘,為了更加清楚地進(jìn)行區(qū)別,實(shí)現(xiàn)了一個(gè)單獨(dú)的UI Gateway挤安,一個(gè)集成了所有UI的節(jié)點(diǎn)谚殊;很顯然,API gateway也是類似的實(shí)現(xiàn)方式蛤铜。這個(gè)微服務(wù)是基于Sping Cloud Gateway框架進(jìn)行實(shí)現(xiàn)的嫩絮。另外一個(gè)可選的方案是Netflix Zuul,它包含在Netflix OSS里围肥,并且通過Spring Cloud Netflix和Spring Boot進(jìn)行集成剿干。
這個(gè)UI gateway使用443端口,使用生成的SSL證書(存放在項(xiàng)目里)穆刻。SSL和HTTPS的配置如下:
server:
port: 443
ssl:
key-store: classpath:keystore.p12
key-store-password: qwerty
key-alias: test_key
key-store-type: PKCS12
用戶名和密碼都存在基于WebFlux規(guī)范的ReactiveUserDetailsService里置尔,它是一個(gè)基于Map的實(shí)現(xiàn):
@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)
}
安全選項(xiàng)設(shè)置如下:
@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()
上面的配置表示部分資源(例如靜態(tài)資源)是所有用戶可以訪問的,以及那些不需要鑒權(quán)的資源氢伟,最后其他的(.anyExchange())都只運(yùn)行登陸用戶訪問榜轿。當(dāng)你訪問一個(gè)需要鑒權(quán)的資源時(shí),你會(huì)被重定向到登陸界面(https://localhost/login):
這個(gè)界面通過Webjars來和我們的項(xiàng)目進(jìn)行交互朵锣,你可以像正趁危客戶端的庫(kù)來依賴管理。Thymeleaf是用來生成HTML頁面的诚些。Login頁面是通過WebFlux進(jìn)行配置的:
@Bean
fun routes() = router {
GET("/login") { ServerResponse.ok().contentType(MediaType.TEXT_HTML).render("login") }
}
Spring Cloud Gateway的路由可以通過YAML或者java config來進(jìn)行配置飞傀。路由可以手動(dòng)進(jìn)行配置,也可以通過接收注冊(cè)中心的數(shù)據(jù)來進(jìn)行配置泣刹。如果需要路由的UI組件的數(shù)量比較大的話,通過注冊(cè)中心集成來進(jìn)行路由會(huì)方便很多椅您。
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”結(jié)尾,url-expression
表示通過HTTP訪問的服務(wù)雪隧,這個(gè)和UI gateway使用HTTPS不同,客戶端的負(fù)載均衡(這里使用Netflix Ribbon)會(huì)被使用员舵。
接下來,我們看一個(gè)使用Java config手動(dòng)配置的一個(gè)實(shí)現(xiàn)(沒有和注冊(cè)中心集成):
@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")
}
}
第一個(gè)路由指向之前所展示的Eureka服務(wù)的主頁(http://localhost:8761
)庄拇,第二條路用來加載當(dāng)前頁面的資源。
應(yīng)用創(chuàng)建的路由都可以通過訪問https://localhost/actuator/gateway/routes
這個(gè)地址來查看。
在底層的微服務(wù)中措近,可能需要用戶在UI gateway里的賬號(hào)或者角色溶弟。為了實(shí)現(xiàn)這個(gè)瞭郑,我添加了一個(gè)過濾器(Filter)來往請(qǐng)求頭里添加相應(yīng)的信息:
@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())
}
}
現(xiàn)在屈张,讓我們通過UI gateway來訪問Item UI — https://localhost/items-ui/greeting
- 立馬能夠驗(yàn)證Item UI已經(jīng)正確地處理了請(qǐng)求頭的信息:
Spring Cloud Sleuth是一個(gè)用來在分布式系統(tǒng)里追蹤請(qǐng)求的一個(gè)解決方案。Trace Id(通過identifier進(jìn)行傳遞)以及Span Id(用來區(qū)分一個(gè)事務(wù)單位)都被加入到跨多個(gè)微服務(wù)的請(qǐng)求里(為了便于理解碳抄,我簡(jiǎn)化了整個(gè)流程场绿,詳細(xì)的信息可以參考這里):
只需要添加spring-cloud-starter-sleuth
的依賴裳凸,這個(gè)功能就可以使用了。
通過添加合適的日志配置逗宁,你就可以在控制臺(tái)里看到微服務(wù)相關(guān)的信息(Trace Id和Span Id都展示在微服務(wù)名稱之后):
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"
如果你想展示調(diào)用關(guān)系的圖狀信息的話瞎颗,你可以使用Zapkin捌议,它會(huì)執(zhí)行服務(wù)請(qǐng)求,然后聚合微服務(wù)HTTP請(qǐng)求頭里的信息倦逐。
構(gòu)建
取決于你的操作系統(tǒng)宫补,使用gradlew clean build
或者./gradlew clean build
粉怕。
如果使用Gradle wrapper,就沒有必要安裝Gradle了贫贝。
在JDK 11.0.1上,能夠正常構(gòu)建并按順序啟動(dòng)崇堵。除此之外,這個(gè)項(xiàng)目在JDK 10上面也是可以工作的,所以我保證這個(gè)版本上運(yùn)行也沒有問題棍辕。但是對(duì)于更早的版本JDK还绘,我沒有任何數(shù)據(jù)支撐。另外需要考慮的一點(diǎn)就是抚太,這里使用的Gradle 5支持只支持JDK 8及以后的版本昔案。
發(fā)布
我建議按照本文介紹的順序來啟動(dòng)應(yīng)用踏揣。如果你使用Intellij IDEA,并且有Run Dashboard的話又谋,你會(huì)看到類似下圖的界面:
結(jié)論
這篇文章里我們介紹了業(yè)內(nèi)建議的基于現(xiàn)代Java技術(shù)棧的微服務(wù)架構(gòu)的一個(gè)實(shí)例娱局,包含主要的組件以及一些特性衰齐。希望這篇文章對(duì)您有所幫助。謝謝仁卷!
參考
Github上的項(xiàng)目代碼
Chris Richardson的微服務(wù)相關(guān)的文章
Martin Fowler的微服務(wù)相關(guān)的文章
Martin Fowler的微服務(wù)的指南