戳上方藍字「 哪咤編程 」關註我
概念
灰度釋出, 也叫金絲雀釋出。是指在黑與白之間,能夠平滑過渡的一種釋出方式。AB test就是一種灰度釋出方式,讓一部份使用者繼續用A,一部份使用者開始用B,如果使用者對B沒有什麽反對意見,那麽逐步擴大範圍,把所有使用者都遷移到B上面來。
灰度釋出可以保證整體系統的穩定,在初始灰度的時候就可以發現、調整問題,以保證其影響度,而我們平常所說的金絲雀部署也就是灰度釋出的一種方式。
具體到伺服器上,實際操作中還可以做更多控制,譬如說,給最初更新的10台伺服器設定較低的權重、控制發送給這10台伺服器的請求數,然後逐漸提高權重、增加請求數。一種平滑過渡的思路, 這個控制叫做「流量切分」。
國內直接使用ChatGPT4o:
用官方一半價格的錢,用跟官方 ChatGPT4.0 一模一樣功能的工具。
國內直接使用 ChatGPT4o :
無需魔法,同時支持電腦、手機,瀏覽器直接使用
ChatGPT3.5永久免費
支持 Chat GPT-4o文本對話、 Copi lot編程、DALL-E AI繪畫、AI語音對話、論文外掛程式Consensus等
長按辨識下方二維碼,備註ai, 發給你
元件版本說明
我們這計畫已經練習了兩年半了使用的版本不是很新,我這裏的Demo也會使用這個版本,有感情了,使用新版本的朋友自己調整一下就行,實作思路是一樣的只是這些框架源碼可能會有變化。
spring-boot: 2.3.12.RELEASE
spring-cloud-dependencies: Hoxton.SR12
spring-cloud-alibaba-dependencies: 2.2.9.RELEASE
核心元件說明
註冊中心: Nacos
閘道器: SpringCloudGateway
負載均衡器: Ribbon (使用SpringCloudLoadBalancer實作也是類似的)
服務間RPC呼叫: OpenFeign
灰度釋出程式碼實作
要實作Spring Cloud計畫灰度釋出技術方案有很多,重點在於服務發現,怎麽將灰度流量只請求到灰度服務,這裏我們會使用Nacos作為註冊中心和配置中心,核心就是利用Nacos的Metadata設定一個version值,在呼叫下遊服務是透過version值來區分要呼叫那個版本,這裏會省略一些流程,文章末尾提供了源碼地址需要自提。
程式碼設計結構
這個是demo計畫,結構都按最簡單的來。
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實作灰度釋出設計(一些基礎資訊類在下面)
在請求進入閘道器時開始對是否要請求灰度版本進行判斷,透過Spring Cloud Gateway的過濾器實作,在呼叫下遊服務時重寫一個Ribbon的負載均衡器實作呼叫時對灰度狀態進行判斷。
存取請求灰度標記Holder(業務服務也是使用的這個)
使用ThreadLocal記錄每個請求執行緒的灰度標記,會在前置過濾器中將標記設定到ThreadLocal中。
public classGrayFlagRequestHolder{
/**
* 標記是否使用灰度版本
* 具體描述請檢視 {@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
的最小值,保證這個過濾器最先執行。
public classGrayGatewayBeginFilterimplementsGlobalFilter, 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 清除避免照成記憶體泄漏。
public classGrayGatewayAfterFilterimplementsGlobalFilter, 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 清除避免照成記憶體泄漏,如果在呼叫下遊業務服務時出現了異常就無法進入後置過濾器。
public classGrayGatewayExceptionHandlerimplementsWebExceptionHandler, 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;
}
}
自訂Ribbon負載均衡路由(業務服務也是使用的這個)
「灰度Ribbon負載均衡路由抽象類:」
這裏提供了兩個獲取服務列表的方法,會對
GrayFlagRequestHolder
中儲存的當前執行緒灰度狀態列舉進行判斷。
如果列舉值為
GrayStatusEnum.ALL
則響應全部服務列表不區分版本,如果列舉值為
GrayStatusEnum.PROD
則返回生產版本的服務列表,如果列舉值為
GrayStatusEnum.GRAY
則返回灰度版本的服務列表,版本號會在
GrayVersionProperties
中配置,透過服務列表中在Nacos的metadata中設定的
version
和
GrayVersionProperties
的版本號進行匹配出對應版本的服務列表。
publicabstract classAbstractGrayLoadBalancerRuleextendsAbstractLoadBalancerRule{
@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;
}
}
「自訂輪詢演算法實作GrayRoundRobinRule:」
程式碼篇幅太長了這裏只截取程式碼片段,我這裏是直接拷貝了Ribbon的輪詢演算法,將裏面獲取服務列表的方法換成了自訂
AbstractGrayLoadBalancerRule
中的方法,其它演算法也可以透過類似的方式實作。
業務服務實作灰度釋出設計
自訂SpringMVC請求攔截器
自訂SpringMVC請求攔截器獲取上遊服務的灰度請求頭,如果獲取到則設定到
GrayFlagRequestHolder
中,之後如果有後續的RPC呼叫同樣的將灰度標記傳遞下去。
@SuppressWarnings("all")
public classGrayMvcHandlerInterceptorimplementsHandlerInterceptor{
@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();
}
}
自訂OpenFeign請求攔截器
自訂OpenFeign請求攔截器,取出自訂SpringMVC請求攔截器中設定到
GrayFlagRequestHolder
中的灰度標識,並且放到呼叫下遊服務的請求頭中,將灰度標記傳遞下去。
public classGrayFeignRequestInterceptorimplementsRequestInterceptor{
@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")
public classGrayGatewayProperties{
/**
* 灰度開關(如果開啟灰度開關則進行灰度邏輯處理,如果關閉則走正常處理邏輯)
* 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")
public classGrayVersionProperties{
/**
* 當前線上版本號
*/
private String prodVersion;
/**
* 灰度版本號
*/
private String grayVersion;
}
全域自動配置類
@Configuration
// 可以透過@ConditionalOnProperty設定是否開啟灰度自動配置 預設是不載入的
@ConditionalOnProperty(value = "kerwin.tool.gray.load",havingValue = "true")
@EnableConfigurationProperties(GrayVersionProperties. class)
public classGrayAutoConfiguration{
@Configuration(proxyBeanMethods = false)
@ConditionalOn class(value = GlobalFilter. class)
@EnableConfigurationProperties(GrayGatewayProperties. class)
static classGrayGatewayFilterAutoConfiguration{
@Bean
public GrayGatewayBeginFilter grayGatewayBeginFilter(){
returnnew GrayGatewayBeginFilter();
}
@Bean
public GrayGatewayAfterFilter grayGatewayAfterFilter(){
returnnew GrayGatewayAfterFilter();
}
@Bean
public GrayGatewayExceptionHandler grayGatewayExceptionHandler(){
returnnew GrayGatewayExceptionHandler();
}
}
@Configuration(proxyBeanMethods = false)
@ConditionalOn class(value = WebMvcConfigurer. class)
static classGrayWebMvcAutoConfiguration{
/**
* Spring MVC 請求攔截器
* @return WebMvcConfigurer
*/
@Bean
public WebMvcConfigurer webMvcConfigurer(){
returnnew WebMvcConfigurer() {
@Override
publicvoidaddInterceptors(InterceptorRegistry registry){
registry.addInterceptor(new GrayMvcHandlerInterceptor());
}
};
}
}
@Configuration
@ConditionalOn class(value = RequestInterceptor. class)
static classGrayFeignInterceptorAutoConfiguration{
/**
* Feign攔截器
* @return GrayFeignRequestInterceptor
*/
@Bean
public GrayFeignRequestInterceptor grayFeignRequestInterceptor(){
returnnew GrayFeignRequestInterceptor();
}
}
}
計畫執行配置
這裏我會啟動五個服務,一個閘道器服務、一個使用者服務V1版本、一個訂單服務V1版本、一個使用者服務V2版本、一個訂單服務V2版本,來演示灰度釋出效果。
❝
PS:Nacos的名稱空間我這裏叫spring-cloud-gray-example可以自己建立一個也可以換成自己的名稱空間,源碼裏面配置都是存在的,有問題看源碼就行
❞配置Nacos全域配置檔(common-config.yaml)
所有服務都會使用到這個配置
kerwin:
tool:
gray:
## 配置是否載入灰度自動配置類,如果不配置那麽預設不載入
load:true
## 配置生產版本和灰度版本號
version:
prodVersion:V1
grayVersion:V2
## 配置Ribbon呼叫user-app和order-app服務時使用我們自訂灰度輪詢演算法
user-app:
ribbon:
NFLoadBalancerRule className:com.kerwin.gray.loadbalancer.GrayRoundRobinRule
order-app:
ribbon:
NFLoadBalancerRule className: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:
-本地
啟動閘道器服務
閘道器服務啟動一個就行,直接Debug啟動即可,方便偵錯源碼
啟動業務服務V1 和 V2版本(使用者服務和訂單服務都用這種方式啟動)
先直接Debug啟動會在IDEA這個位置看到一個對應啟動類名稱的資訊
點選Edit編輯這個啟動配置
復制一個對應啟動配置作為V2版本,自己將Name改成自己能區分的即可
配置啟動參數,第一步點選
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。
最後啟動好的服務資訊
灰度效果演示
源碼中的user-app提供了一個獲取使用者資訊的介面並且會攜帶當前服務的埠和版本資訊,order-app服務提供了一個獲取訂單資訊的介面,會去遠端呼叫user-app獲取訂單關聯的使用者資訊,並且也會攜帶當前服務的埠和版本資訊響應。
場景一(關閉灰度開關:不區分呼叫服務版本)
關閉灰度開關有兩個配置可以實作
1、在計畫啟動之前修改Nacos全域配置檔中的
kerwin.tool.gray.load
配置是否載入灰度自動配置類,只要配置不為true就不會載入整個灰度相關類
2、關閉閘道器灰度開關,修改閘道器Nacos配置檔中的
kerwin.tool.gray.gateway.enabled
,只要配置不為true就不會進行灰度判斷。
呼叫演示
這裏呼叫不一定就是Order服務版本為V1 User服務版本也為V1,也有可能Order服務版本為V1 User服務版本也為V2.
第一次呼叫,Order服務版本為V1,User服務版本也為V1
第二次呼叫,Order服務版本為V2,User服務版本也為V2
場景二(開啟灰度開關:只呼叫生產版本)
修改閘道器Nacos配置檔中的
kerwin.tool.gray.gateway.enabled
設定為true,其它灰度IP陣列和城市陣列配置匹配不上就行,這樣怎麽呼叫都是V1版本,因為在
GrayVersionProperties
版本配置中設定的生產版本就是為V1灰度版本為V2。
場景三(開啟灰度開關:透過請求頭、ip、城市匹配呼叫灰度版本)
這裏透過請求頭測試,攜帶請求頭
gray=gray-996
存取閘道器那麽流量就會都進入灰度版本V2。
源碼
❝
https://gitee.com/kerwin_code/spring-cloud-gray-example
❞存在問題
1、如果計畫中使用到了分布式任務排程那怎麽區分灰度版本
這裏其實挺好解決的,就拿xxl-job來說,註冊不同的執行器就行,在釋出灰度版本時註冊到灰度版本的執行器即可。
2、如果計畫中使用的了MQ我們收發訊息怎麽控制灰度
這裏和解決分布式任務排程思想是一樣的灰度版本的服務發送訊息的時候投遞到另外一個MQ的伺服端,就是弄兩套MQ伺服端,生產的服務使用生產的MQ,灰度釋出使用灰度的MQ
3、這裏整個實作流程不是很復雜,但也是很沒必要,只是提供一種實作方案可以參考
其實透過Nginx + Lua指令碼方式直接路由閘道器,然後給灰度整套服務都使用一個Nacos灰度的名稱空間,生產的使用生產的名稱空間,這樣就能將兩套服務都隔離了,分布式任務排程、MQ等配置都可以獨立在自己名稱空間的配置檔中豈不美哉
來源: https://blog.csdn.net/weixin_44606481
·················END·················
用官方一半價格的錢,用跟官方 ChatGPT4.0 一模一樣功能的工具。
國內直接使用ChatGPT4o:
無需魔法,同時支持手機、電腦,瀏覽器直接使用
帳號獨享
ChatGPT3.5永久免費
長按辨識下方二維碼,備註ai,發給你
回復gpt,獲取ChatGPT4o直接使用地址
點選閱讀原文,國內直接使用ChatGpt4o