-
《專案實戰(影片)》:從書中學,往事上“練” -
《網際網路高頻面試題》:面朝簡歷學習,春暖花開 -
《架構 x 系統設計》:摧枯拉朽,掌控面試高頻場景題 -
《精進 Java 學習指南》:系統學習,網際網路主流技術棧 -
《必讀 Java 原始碼專欄》:知其然,知其所以然

-
Boot 倉庫:https://gitee.com/zhijiantianya/ruoyi-vue-pro -
Cloud 倉庫:https://gitee.com/zhijiantianya/yudao-cloud -
影片教程:https://doc.iocoder.cn
概念
-
專案地址:https://github.com/YunaiV/ruoyi-vue-pro -
影片教程:https://doc.iocoder.cn/video/
元件版本說明
-
spring-boot: 2.3.12.RELEASE -
spring-cloud-dependencies: Hoxton.SR12 -
spring-cloud-alibaba-dependencies: 2.2.9.RELEASE
-
專案地址:https://github.com/YunaiV/yudao-cloud -
影片教程:https://doc.iocoder.cn/video/
核心元件說明
-
註冊中心: Nacos -
閘道器: SpringCloudGateway -
負載均衡器: Ribbon (使用SpringCloudLoadBalancer實現也是類似的) -
服務間RPC呼叫: OpenFeign
灰度釋出程式碼實現

程式碼設計結構
spring-cloud-gray-example // 父工程
kerwin-common // 專案公共模組
kerwin-gateway // 微服務閘道器
kerwin-order // 訂單模組
order-app // 訂單業務服務
kerwin-starter // 自定義springboot starter模組
spring-cloud-starter-kerwin-gray // 灰度釋出starter包 (核心程式碼都在這裡)
kerwin-user // 使用者模組
user-app // 使用者業務服務
user-client // 使用者client(Feign和DTO)
核心包spring-cloud-starter-kerwin-gray結構介紹

入口Spring Cloud Gateway實現灰度釋出設計(一些基礎資訊類在下面)
publicclassGrayFlagRequestHolder
{
/**
* 標記是否使用灰度版本
* 具體描述請檢視 {
@link
com.kerwin.gray.enums.GrayStatusEnum}
*/
privatestaticfinal
ThreadLocal<GrayStatusEnum> grayFlag =
new
ThreadLocal<>();
publicstaticvoidsetGrayTag(final GrayStatusEnum tag)
{
grayFlag.set(tag);
}
publicstatic GrayStatusEnum getGrayTag()
{
return
grayFlag.get();
}
publicstaticvoidremove()
{
grayFlag.remove();
}
}
GrayStatusEnum
設定到GrayRequestContextHolder
中儲存這一個請求的灰度狀態列舉,在負載均衡器中會取出灰度狀態列舉判斷要呼叫那個版本的服務,同時這裡還實現了Ordered 介面會對閘道器的過濾器進行的排序,這裡我們將這個過濾器的排序設定為Ordered.HIGHEST_PRECEDENCE int
的最小值,保證這個過濾器最先執行。publicclassGrayGatewayBeginFilterimplementsGlobalFilter, Ordered
{
@Autowired
private
GrayGatewayProperties grayGatewayProperties;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain)
{
GrayStatusEnum grayStatusEnum = GrayStatusEnum.ALL;
// 當灰度開關開啟時才進行請求頭判斷
if
(grayGatewayProperties.getEnabled()) {
grayStatusEnum = GrayStatusEnum.PROD;
// 判斷是否需要呼叫灰度版本
if
(checkGray(exchange.getRequest())) {
grayStatusEnum = GrayStatusEnum.GRAY;
}
}
GrayFlagRequestHolder.setGrayTag(grayStatusEnum);
ServerHttpRequest newRequest = exchange.getRequest().mutate()
.header(GrayConstant.GRAY_HEADER, grayStatusEnum.getVal())
.build();
ServerWebExchange newExchange = exchange.mutate()
.request(newRequest)
.build();
return
chain.filter(newExchange);
}
/**
* 校驗是否使用灰度版本
*/
privatebooleancheckGray(ServerHttpRequest request)
{
if
(checkGrayHeadKey(request) || checkGrayIPList(request) || checkGrayCiryList(request) || checkGrayUserNoList(request)) {
returntrue
;
}
returnfalse
;
}
/**
* 校驗自定義灰度版本請求頭判斷是否需要呼叫灰度版本
*/
privatebooleancheckGrayHeadKey(ServerHttpRequest request)
{
HttpHeaders headers = request.getHeaders();
if
(headers.containsKey(grayGatewayProperties.getGrayHeadKey())) {
List<String> grayValues = headers.get(grayGatewayProperties.getGrayHeadKey());
if
(!Objects.isNull(grayValues)
&& grayValues.size() >
0
&& grayGatewayProperties.getGrayHeadValue().equals(grayValues.get(
0
))) {
returntrue
;
}
}
returnfalse
;
}
/**
* 校驗自定義灰度版本IP陣列判斷是否需要呼叫灰度版本
*/
privatebooleancheckGrayIPList(ServerHttpRequest request)
{
List<String> grayIPList = grayGatewayProperties.getGrayIPList();
if
(CollectionUtils.isEmpty(grayIPList)) {
returnfalse
;
}
String realIP = request.getHeaders().getFirst(
"X-Real-IP"
);
if
(realIP ==
null
|| realIP.isEmpty()) {
realIP = request.getRemoteAddress().getAddress().getHostAddress();
}
if
(realIP !=
null
&& CollectionUtils.contains(grayIPList.iterator(), realIP)) {
returntrue
;
}
returnfalse
;
}
/**
* 校驗自定義灰度版本城市陣列判斷是否需要呼叫灰度版本
*/
privatebooleancheckGrayCiryList(ServerHttpRequest request)
{
List<String> grayCityList = grayGatewayProperties.getGrayCityList();
if
(CollectionUtils.isEmpty(grayCityList)) {
returnfalse
;
}
String realIP = request.getHeaders().getFirst(
"X-Real-IP"
);
if
(realIP ==
null
|| realIP.isEmpty()) {
realIP = request.getRemoteAddress().getAddress().getHostAddress();
}
// 透過IP獲取當前城市名稱
// 這裡篇幅比較長不具體實現了,想要實現的可以使用ip2region.xdb,這裡寫死cityName = "本地"
String cityName =
"本地"
;
if
(cityName !=
null
&& CollectionUtils.contains(grayCityList.iterator(), cityName)) {
returntrue
;
}
returnfalse
;
}
/**
* 校驗自定義灰度版本使用者編號陣列(我們系統不會在閘道器獲取使用者編號這種方法如果需要可以自己實現一下)
*/
privatebooleancheckGrayUserNoList(ServerHttpRequest request)
{
List<String> grayUserNoList = grayGatewayProperties.getGrayUserNoList();
if
(CollectionUtils.isEmpty(grayUserNoList)) {
returnfalse
;
}
returnfalse
;
}
@Override
publicintgetOrder()
{
// 設定過濾器的執行順序,值越小越先執行
return
Ordered.HIGHEST_PRECEDENCE;
}
}
GrayFlagRequestHolder
中的 ThreadLocal 清除避免照成記憶體洩漏。publicclassGrayGatewayAfterFilterimplementsGlobalFilter, Ordered
{
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain)
{
// 請求執行完必須要remore當前執行緒的ThreadLocal
GrayFlagRequestHolder.remove();
return
chain.filter(exchange);
}
@Override
publicintgetOrder()
{
// 設定過濾器的執行順序,值越小越先執行
return
Ordered.LOWEST_PRECEDENCE;
}
}
GrayFlagRequestHolder
中的 ThreadLocal 清除避免照成記憶體洩漏,如果在呼叫下游業務服務時出現了異常就無法進入後置過濾器。publicclassGrayGatewayExceptionHandlerimplementsWebExceptionHandler, Ordered
{
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex)
{
// 請求執行完必須要remore當前執行緒的ThreadLocal
GrayFlagRequestHolder.remove();
ServerHttpResponse response = exchange.getResponse();
if
(ex
instanceof
ResponseStatusException) {
// 處理 ResponseStatusException 異常
ResponseStatusException responseStatusException = (ResponseStatusException) ex;
response.setStatusCode(responseStatusException.getStatus());
// 可以根據需要設定響應頭等
return
response.setComplete();
}
else
{
// 處理其他異常
response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
// 可以根據需要設定響應頭等
return
response.setComplete();
}
}
@Override
publicintgetOrder()
{
// 設定過濾器的執行順序,值越小越先執行
return
Ordered.HIGHEST_PRECEDENCE;
}
}
GrayFlagRequestHolder
中儲存的當前執行緒灰度狀態列舉進行判斷,如果列舉值為GrayStatusEnum.ALL
則響應全部服務列表不區分版本,如果列舉值為GrayStatusEnum.PROD
則返回生產版本的服務列表,如果列舉值為GrayStatusEnum.GRAY
則返回灰度版本的服務列表,版本號會在GrayVersionProperties
中配置,透過服務列表中在Nacos的metadata中設定的version
和GrayVersionProperties
的版本號進行匹配出對應版本的服務列表。publicabstractclassAbstractGrayLoadBalancerRuleextendsAbstractLoadBalancerRule
{
@Autowired
private
GrayVersionProperties grayVersionProperties;
@Value
(
"${spring.cloud.nacos.discovery.metadata.version}"
)
private
String metaVersion;
/**
* 只有已啟動且可訪問的伺服器,並對灰度標識進行判斷
*/
public List<Server> getReachableServers()
{
ILoadBalancer lb = getLoadBalancer();
if
(lb ==
null
) {
returnnew
ArrayList<>();
}
List<Server> reachableServers = lb.getReachableServers();
return
getGrayServers(reachableServers);
}
/**
* 所有已知的伺服器,可訪問和不可訪問,並對灰度標識進行判斷
*/
public List<Server> getAllServers()
{
ILoadBalancer lb = getLoadBalancer();
if
(lb ==
null
) {
returnnew
ArrayList<>();
}
List<Server> allServers = lb.getAllServers();
return
getGrayServers(allServers);
}
/**
* 獲取灰度版本服務列表
*/
protected List<Server> getGrayServers(List<Server> servers)
{
List<Server> result =
new
ArrayList<>();
if
(servers ==
null
) {
return
result;
}
String currentVersion = metaVersion;
GrayStatusEnum grayStatusEnum = GrayFlagRequestHolder.getGrayTag();
if
(grayStatusEnum !=
null
) {
switch
(grayStatusEnum) {
case
ALL:
return
servers;
case
PROD:
currentVersion = grayVersionProperties.getProdVersion();
break
;
case
GRAY:
currentVersion = grayVersionProperties.getGrayVersion();
break
;
}
}
for
(Server server : servers) {
NacosServer nacosServer = (NacosServer) server;
Map<String, String> metadata = nacosServer.getMetadata();
String version = metadata.get(
"version"
);
// 判斷服務metadata下的version是否於設定的請求版本一致
if
(version !=
null
&& version.equals(currentVersion)) {
result.add(server);
}
}
return
result;
}
}
AbstractGrayLoadBalancerRule
中的方法,其它演算法也可以透過類似的方式實現。
業務服務實現灰度釋出設計
GrayFlagRequestHolder
中,之後如果有後續的RPC呼叫同樣的將灰度標記傳遞下去。@SuppressWarnings
(
"all"
)
publicclassGrayMvcHandlerInterceptorimplementsHandlerInterceptor
{
@Override
publicbooleanpreHandle(HttpServletRequest request, HttpServletResponse response, Object handler)throws Exception
{
String grayTag = request.getHeader(GrayConstant.GRAY_HEADER);
// 如果HttpHeader中灰度標記存在,則將灰度標記放到holder中,如果需要就傳遞下去
if
(grayTag!=
null
) {
GrayFlagRequestHolder.setGrayTag(GrayStatusEnum.getByVal(grayTag));
}
returntrue
;
}
@Override
publicvoidpostHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView)throws Exception
{
}
@Override
publicvoidafterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)throws Exception
{
GrayFlagRequestHolder.remove();
}
}
GrayFlagRequestHolder
中的灰度標識,並且放到呼叫下游服務的請求頭中,將灰度標記傳遞下去。publicclassGrayFeignRequestInterceptorimplementsRequestInterceptor
{
@Override
publicvoidapply(RequestTemplate template)
{
// 如果灰度標記存在,將灰度標記透過HttpHeader傳遞下去
GrayStatusEnum grayStatusEnum = GrayFlagRequestHolder.getGrayTag();
if
(grayStatusEnum !=
null
) {
template.header(GrayConstant.GRAY_HEADER, Collections.singleton(grayStatusEnum.getVal()));
}
}
}
基礎資訊設計
-
呼叫業務服務時設定的灰度統一請求頭
publicinterfaceGrayConstant
{
/**
* 灰度統一請求頭
*/
String GRAY_HEADER=
"gray"
;
}
-
灰度版本狀態列舉
publicenum
GrayStatusEnum {
ALL(
"ALL"
,
"可以呼叫全部版本的服務"
),
PROD(
"PROD"
,
"只能呼叫生產版本的服務"
),
GRAY(
"GRAY"
,
"只能呼叫灰度版本的服務"
);
GrayStatusEnum(String val, String desc) {
this
.val = val;
this
.desc = desc;
}
private
String val;
private
String desc;
public String getVal()
{
return
val;
}
publicstatic GrayStatusEnum getByVal(String val)
{
if
(val ==
null
){
returnnull
;
}
for
(GrayStatusEnum value : values()) {
if
(value.val.equals(val)){
return
value;
}
}
returnnull
;
}
}
-
閘道器灰度配置資訊類
@Data
@Configuration
@RefreshScope
@ConfigurationProperties
(
"kerwin.tool.gray.gateway"
)
publicclassGrayGatewayProperties
{
/**
* 灰度開關(如果開啟灰度開關則進行灰度邏輯處理,如果關閉則走正常處理邏輯)
* PS:一般在灰度釋出測試完成以後會將線上版本都切換成灰度版本完成全部升級,這時候應該關閉灰度邏輯判斷
*/
private
Boolean enabled =
false
;
/**
* 自定義灰度版本請求頭 (透過grayHeadValue來匹配請求頭中的值如果一致就去呼叫灰度版本,用於公司測試)
*/
private
String grayHeadKey=
"gray"
;
/**
* 自定義灰度版本請求頭匹配值
*/
private
String grayHeadValue=
"gray-996"
;
/**
* 使用灰度版本IP陣列
*/
private
List<String> grayIPList =
new
ArrayList<>();
/**
* 使用灰度版本城市陣列
*/
private
List<String> grayCityList =
new
ArrayList<>();
/**
* 使用灰度版本使用者編號陣列(我們系統不會在閘道器獲取使用者編號這種方法如果需要可以自己實現一下)
*/
private
List<String> grayUserNoList =
new
ArrayList<>();
}
-
全域性版本配置資訊類
@Data
@Configuration
@RefreshScope
@ConfigurationProperties
(
"kerwin.tool.gray.version"
)
publicclassGrayVersionProperties
{
/**
* 當前線上版本號
*/
private
String prodVersion;
/**
* 灰度版本號
*/
private
String grayVersion;
}
-
全域性自動配置類
@Configuration
// 可以透過@ConditionalOnProperty設定是否開啟灰度自動配置 預設是不載入的
@ConditionalOnProperty
(value =
"kerwin.tool.gray.load"
,havingValue =
"true"
)
@EnableConfigurationProperties
(GrayVersionProperties
.
class
)
publicclassGrayAutoConfiguration
{
@Configuration
(proxyBeanMethods =
false
)
@ConditionalOnClass
(value = GlobalFilter
.
class
)
@
EnableConfigurationProperties
(
GrayGatewayProperties
.
class
)
staticclassGrayGatewayFilterAutoConfiguration
{
@Bean
public GrayGatewayBeginFilter grayGatewayBeginFilter()
{
returnnew
GrayGatewayBeginFilter();
}
@Bean
public GrayGatewayAfterFilter grayGatewayAfterFilter()
{
returnnew
GrayGatewayAfterFilter();
}
@Bean
public GrayGatewayExceptionHandler grayGatewayExceptionHandler()
{
returnnew
GrayGatewayExceptionHandler();
}
}
@Configuration
(proxyBeanMethods =
false
)
@ConditionalOnClass
(value = WebMvcConfigurer
.
class
)
staticclassGrayWebMvcAutoConfiguration
{
/**
* Spring MVC 請求攔截器
*
@return
WebMvcConfigurer
*/
@Bean
public WebMvcConfigurer webMvcConfigurer()
{
returnnew
WebMvcConfigurer() {
@Override
publicvoidaddInterceptors(InterceptorRegistry registry)
{
registry.addInterceptor(
new
GrayMvcHandlerInterceptor());
}
};
}
}
@Configuration
@ConditionalOnClass
(value = RequestInterceptor
.
class
)
staticclassGrayFeignInterceptorAutoConfiguration
{
/**
* Feign攔截器
*
@return
GrayFeignRequestInterceptor
*/
@Bean
public GrayFeignRequestInterceptor grayFeignRequestInterceptor()
{
returnnew
GrayFeignRequestInterceptor();
}
}
}
專案執行配置
配置Nacos全域性配置檔案(common-config.yaml)
kerwin:
tool:
gray:
# 配置是否載入灰度自動配置類,如果不配置那麼預設不載入
load:
true
# 配置生產版本和灰度版本號
version:
prodVersion: V1
grayVersion: V2
# 配置Ribbon呼叫user-app和order-app服務時使用我們自定義灰度輪詢演算法
user-app:
ribbon:
NFLoadBalancerRuleClassName: com.kerwin.gray.loadbalancer.GrayRoundRobinRule
order-app:
ribbon:
NFLoadBalancerRuleClassName: com.kerwin.gray.loadbalancer.GrayRoundRobinRule

配置閘道器Nacos配置檔案(gateway-app.yaml)
kerwin:
tool:
gray:
gateway:
# 是否開啟灰度釋出功能
enabled:
true
# 自定義灰度版本請求頭
grayHeadKey: gray
# 自定義灰度版本請求頭匹配值
grayHeadValue: gray-
996
# 使用灰度版本IP陣列
grayIPList:
-
'127.0.0.1'
# 使用灰度版本城市陣列
grayCityList:
- 本地

啟動閘道器服務
啟動業務服務V1 和 V2版本(使用者服務和訂單服務都用這種方式啟動)



Modify options
然後第二步將Add VM options
勾選上,第三步填寫對應服務的啟動埠和Nacos的metadata.version
,我這裡使用者服務V1版本配置為-Dserver.port=7201
-Dspring.cloud.nacos.discovery.metadata.version=V1
,使用者服務V2版本配置為-Dserver.port=7202
-Dspring.cloud.nacos.discovery.metadata.version=V2
,訂單服務配置類似,配置好後點Apply。

灰度效果演示
場景一(關閉灰度開關:不區分呼叫服務版本)
kerwin.tool.gray.load
配置是否載入灰度自動配置類,只要配置不為true就不會載入整個灰度相關類
kerwin.tool.gray.gateway.enabled
,只要配置不為true就不會進行灰度判斷。-
第一次呼叫,Order服務版本為V1,User服務版本也為V1

-
第二次呼叫,Order服務版本為V2,User服務版本也為V2

場景二(開啟灰度開關:只調用生產版本)
kerwin.tool.gray.gateway.enabled
設定為true,其它灰度IP陣列和城市陣列配置匹配不上就行,這樣怎麼呼叫都是V1版本,因為在GrayVersionProperties
版本配置中設定的生產版本就是為V1灰度版本為V2。

場景三(開啟灰度開關:透過請求頭、ip、城市匹配呼叫灰度版本)
gray=gray-996
訪問閘道器那麼流量就會都進入灰度版本V2。

原始碼
存在問題
1、如果專案中使用到了分散式任務排程那怎麼區分灰度版本
2、如果專案中使用的了MQ我們收發訊息怎麼控制灰度
3、這裡整個實現流程不是很複雜,但也是很沒必要,只是提供一種實現方案可以參考





