代碼實(shí)戰(zhàn):從單體式應(yīng)用到微服務(wù)的低風(fēng)險演變

本文獲得blog.christianposta授權(quán)翻譯發(fā)表,轉(zhuǎn)載需要注明來自公眾號EAWorld。


作者:Christian Posta?

譯者:海松?

原題:Low-risk Monolith to Microservice Evolution Part II


繼續(xù)來深入探討!在之前的文章(第一部分)中,我們?yōu)楸酒恼陆⒘艘粋€上下文環(huán)境(以便于討論)。一個基本原則是,當(dāng)微服務(wù)被引入到現(xiàn)有架構(gòu)中時,不能也不應(yīng)該破壞當(dāng)前的請求流程(request flows)?!皢误w應(yīng)用(monolish)”程序依然能帶來很多商業(yè)價值(因此仍將在新的時代被使用,編者注),我們只能在迭代和擴(kuò)展時,盡可能地減少其負(fù)面影響,這過程中就有一個經(jīng)常被忽略的事實(shí):當(dāng)我們開始探索如何從單體應(yīng)用過渡到微服務(wù)時,會遇到一些我們不愿意碰到的難題,但顯然我們不能視而不見。如果你還沒讀過這段內(nèi)容,我建議你再回去看看第一部分。同時也可以參考什么時候不要做微服務(wù)[0]。


關(guān)注推特上的(@christianposta)或訪問http://blog.christianposta.com,以獲取最新更新和討論。


在此前的第一部分,想解決的問題有:?

  • 如何可以有效可靠地生成微服務(wù)。以及如何建立一個持續(xù)交付的系統(tǒng)。?

  • 如何能夠?qū)Ψ?wù)和單體應(yīng)用等對象進(jìn)行測試。?

  • 如何在新的微服務(wù)中能安全地引入任何變更,包含灰度上線、金絲雀測試等等?

  • 如何將流量路由到新的服務(wù)中去,以保證啟用/終止任何新的特性或更改都不會出現(xiàn)問題?

  • 如何面對許多棘手的數(shù)據(jù)集成挑戰(zhàn)


一、技術(shù)層面


以下這些技術(shù)在我們的實(shí)踐過程中將具備一定的指導(dǎo)作用:

? 開發(fā)人員服務(wù)框架(Spring Boot?[1]WildFly?[2],WildFly Swarm?[3])?

? API設(shè)計(jì)(APICur.io?[4])?

? 數(shù)據(jù)框架(Spring Boot Teiid?[5],Debezium.io?[6])?

? 集成工具(Apache Camel?[7])?

? Service Mesh(Istio Service Mesh?[8])?

? 數(shù)據(jù)庫遷移工具(Liquibase [9])?

? 灰度上線/特性標(biāo)記框架(FF4J?[10])?

? 部署/CI-CD平臺(Kubernetes?[11]/OpenShift?[12])?

? Kubernetes開發(fā)工具(Fabric8.io?[13])?

? 測試工具(Arquillian?[14]Pact?[15]/Arquillian Algeron?[16],Hoverfly?[17],Spring-Boot Test?[18],RestAssured?[19],Arquillian Cube?[20]


我使用的是http://developers.redhat.com上的TicketMonster教程,顯示從單體應(yīng)用到微服務(wù)的演變,如果感興趣的話可以關(guān)注,你還可以在github上找到相關(guān)的代碼和文檔(文檔還在編寫中):https://github.com/ticket-monster-msa/monolith


讓我們一步步地讀完第一部分?[21],具體來看看每一步應(yīng)該怎么實(shí)施。中間還會引入上一部分中出現(xiàn)的一些注意事項(xiàng),并在當(dāng)前背景下再討論一遍。


二、了解單體式應(yīng)用


回顧下注意事項(xiàng):

  • 單體式應(yīng)用(代碼和數(shù)據(jù)庫模型)很難變更?

  • 變更需要整體重新部署和團(tuán)隊(duì)間高度的協(xié)調(diào)?

  • 需要進(jìn)行大量測試來做回歸分析?

  • 需要一個全自動的部署方式


可以的話,盡可能為單體應(yīng)用安排大量的測試,哪怕不是一直有效。隨著演變的開始,無論是添加新功能還是替換現(xiàn)有功能,我們都需要清楚了解任何更改可能產(chǎn)生的影響。Michael Feathers 在他《重構(gòu)遺留代碼》[22]的書中,將“遺留代碼(legacy code)”定義為沒有被測試所覆蓋的代碼。像JUnit和Arquillian這樣的工具就很能幫到大忙。使用Arquillian,可以任意選擇遠(yuǎn)程方法調(diào)用的接口的顆粒大?。╢ine grain or coarse grain),然后打包應(yīng)用程序,不過仍需要用適當(dāng)?shù)哪M等方式,來運(yùn)行打算被測試的一部分程序。例如,在單體應(yīng)用(TicketMonster)中,我們可以定義一個微部署(micro-deployment),用來將原有的數(shù)據(jù)庫替換為內(nèi)存數(shù)據(jù)庫,并預(yù)加載一些樣例數(shù)據(jù)。Arquillian適用于Spring Boot應(yīng)用、Java EE等。在本例中,我們將測試一個Java EE的單體架構(gòu):

public static WebArchive deployment() {
?return ShrinkWrap
? ?.create(WebArchive.class, "test.war")
? ?.addPackage(Resources.class.getPackage())
? ?.addAsResource("META-INF/test-persistence.xml", "META-INF/persistence.xml")
? ?.addAsResource("import.sql")
? ?.addAsWebInfResource(EmptyAsset.INSTANCE, "beans.xml")
? ?// Deploy our test datasource
? ?.addAsWebInfResource("test-ds.xml");
}

更有意思的是,嵌入在運(yùn)行環(huán)境中的測試可以用來驗(yàn)證內(nèi)部工作的所有組件。例如,在上面的一個測試中,我們可以將BookingService注入到測試中,并直接運(yùn)行:

@RunWith(Arquillian.class)
public class BookingServiceTest {

? ?@Deployment
? ?public static WebArchive deployment() {
? ? ? ?return RESTDeployment.deployment();
? ?}

? ?@Inject
? ?private BookingService bookingService;

? ?@Inject
? ?private ShowService showService;

? ?@Test
? ?@InSequence(1)
? ?public void testCreateBookings() {
? ? ? ?BookingRequest br = createBookingRequest(1l, 0, new int[]{4, 1}, new int[]{1,1}, new int[]{3,1});
? ? ? ?bookingService.createBooking(br);

? ? ? ?BookingRequest br2 = createBookingRequest(2l, 1, new int[]{6,1}, new int[]{8,2}, new int[]{10,2});
? ? ? ?bookingService.createBooking(br2);

? ? ? ?BookingRequest br3 = createBookingRequest(3l, 0, new int[]{4,1}, new int[]{2,1});
? ? ? ?bookingService.createBooking(br3);
? ?}

完整的示例請參閱TicketMonster單體應(yīng)用模塊[23]中的BookingServiceTest。


測試的問題解決了,那么部署呢?


Kubernetes已成為容器化服務(wù)或應(yīng)用程序的實(shí)際部署平臺。Kubernetes處理諸如健康度檢查、擴(kuò)展、重啟、負(fù)載平衡等事項(xiàng)。對于Java開發(fā)人員來說,像fabric8-maven-plugin[24]這樣的工具甚至都可以用來自動構(gòu)建容器或docker鏡像,并生成任意部署資源文件。OpenShift[25]是Red Hat的Kubernetes的產(chǎn)品化版本,其中增加了開發(fā)人員的功能,包括CI/CD pipelines等。



無論是微服務(wù)、單體應(yīng)用還是其他平臺(比如能夠處理持續(xù)的工作負(fù)載,即數(shù)據(jù)庫等),Kubernetes/OpenShift都是一個適用于應(yīng)用程序/服務(wù)的部署平臺。通過Arquillian,容器和OpenShift pipelines,可以持續(xù)地將變更引入生產(chǎn)環(huán)境。順便來看一下openshift.io[26],它將開發(fā)經(jīng)驗(yàn)與自動CI/CD pipelines、SCM集成、Eclipse Che[27]開發(fā)人員工作區(qū)、庫掃描等結(jié)合在一起。


目前,生產(chǎn)負(fù)載指向單體應(yīng)用。如果我們翻到它的主頁,我們會看到這樣的內(nèi)容:



接下來,讓我們開始做一些改變…

三、提取用戶界面UI



回顧下注意事項(xiàng):

  • 一開始,先不要變更單體式應(yīng)用;只需將UI復(fù)制粘貼到單獨(dú)的組件即可?

  • 在UI和單體式應(yīng)用間需要有一個合適的遠(yuǎn)程API—但并非所有情況下都需要?

  • 增加一個安全層?

  • 需要用某種方法以受控的方式將流量路由或分離到新的UI或單體式應(yīng)用,以支持灰度上線(dark launch)/金絲雀測試(canary)/滾動發(fā)布(rolling release[28]


如果我們看下TicketMonster UI v1 [29]代碼,就會發(fā)現(xiàn)它非常簡單。靜態(tài)HTML/JS/CSS組件已經(jīng)被移到它自己的Web服務(wù)器,還被打包到一個容器中。通過這種方式,我們可以在單體應(yīng)用之外對它進(jìn)行單獨(dú)部署,并獨(dú)立更改或更新版本。這個UI項(xiàng)目仍然需要與單體應(yīng)用對話來執(zhí)行它的功能,所以應(yīng)該是公開一個REST接口,讓UI可以與之交互。對于一些單體應(yīng)用來說,這說起來容易做起來難。如果你想從遺留代碼中打包出來一個不錯的REST API,又遇到了挑戰(zhàn),我強(qiáng)烈推薦你看看Apache Camel,尤其是它的REST DSL。


比較有意思的是,實(shí)際上單體應(yīng)用并沒有被改變。它的代碼沒有變動,同時新UI也部署完成。如果查看Kubernetes,我們會看到兩個單獨(dú)的部署對象和兩個單獨(dú)的pod:一個用于單體架構(gòu),另一個用于UI。



即使tm-ui-v1用戶界面部署完了,也沒有任何流量進(jìn)入這個新的TicketMonster UI組件。為了簡單起見,即使這個部署并沒有承載生產(chǎn)流量,而是ticket-monster這個單體應(yīng)用在承擔(dān)所有流量,我們?nèi)匀豢梢园阉?dāng)作一個簡單的灰度上線。相關(guān)的UI端口仍舊可以訪問:


接下來,用kubectl cli 工具從本地端口轉(zhuǎn)發(fā)到特定的pod(端口80上的tm-ui-v1-3105082891-gh31x),并將其映射到本地端口8080。現(xiàn)在,如果導(dǎo)航到http://localhost:8080,應(yīng)該得到一個新版本UI(注意突出顯示的文本部分,表明這是一個不同的UI,但它直接指向單體應(yīng)用)



如果我們這個新版本還算滿意,就可以開始將流量引入進(jìn)來。為此,我們將使用Istio service mesh [30]。Istio是用于管理由入口點(diǎn)和服務(wù)代理組成的網(wǎng)格控制層(control plane)。我已經(jīng)寫了一些關(guān)于像Envoy這樣的數(shù)據(jù)層[31]以及service mesh[32]的文章。我個人強(qiáng)烈建議看看Istio的全部功能。接下來的幾段內(nèi)容,我們會圍繞整個項(xiàng)目的全過程來依次展開討論Istio的各項(xiàng)功能。如果控制層和數(shù)據(jù)層之間的區(qū)分讓你困惑,請查看Matt Klein[33]撰寫的博客。


我們將從使用Istio Ingress Controller[34]開始。該組件允許使用Kubernetes Ingress規(guī)范來控制流量進(jìn)入Kubernetes集群。一旦安裝了Istio,我們可以這樣創(chuàng)建一個入口資源,將流量指向Ticket Monster UI的Kubernetes服務(wù),tm-ui:


apiVersion: extensions/v1beta1
kind: Ingress
metadata:
?name: tm-gateway
?annotations:
? ?kubernetes.io/ingress.class: "istio"
spec:
?backend:
? ?serviceName: tm-ui
? ?servicePort: 80

一旦有了入口,就可以開始應(yīng)用Istio路由規(guī)則[35]。例如,有一個規(guī)則,“任何時候有人試圖與在Kubernetes中運(yùn)行的tm-ui服務(wù)對話,將它們指向服務(wù)的第一版本v1”:


apiVersion: config.istio.io/v1alpha2
kind: RouteRule
metadata:
?name: tm-ui-default
spec:
?destination:
? ?name: tm-ui
?precedence: 1
?route:
?- labels:
? ? ?version: v1

如此,我們能夠更好地控制進(jìn)入集群甚至深入集群內(nèi)部的流量。在這個步驟的最后,我們會將所有的流量都轉(zhuǎn)到tm-ui-v1部署。


四、從單體架構(gòu)移除UI


回顧下注意事項(xiàng)

  • 從單體式應(yīng)用中移除UI組件?

  • 需要對單體式應(yīng)用進(jìn)行最小的變更(棄用/刪除/禁用UI)?

  • 不停機(jī)的前提下,再次使用受控的路由/整流方法來引入這種變更


這一步相當(dāng)直接,通過刪除靜態(tài)UI組件來更新單體應(yīng)用(刪除的部分已經(jīng)轉(zhuǎn)移到了tm-ui-v1部署)。既然應(yīng)用程序已經(jīng)被釋放成為一個單體應(yīng)用的服務(wù),以供UI,API或者其他一些程序調(diào)用,那么也可以對這個部署進(jìn)行一些API層級的更改。而如果想對API進(jìn)行一些更改,就需要部署一個新版本的UI。此處我們部署了backend-v1服務(wù)以及一個新的UI tm-ui-v2,可以利用后端服務(wù)中的這個新API。


來看看在Kubernetes集群中的部署情況:


此時,ticket-monster和tm-ui-v1正接收實(shí)時流量。backend-v1和指向它的UI--tm-ui-v2則沒有流量負(fù)載。需要注意的一點(diǎn)是,backend-v1部署與ticket-monster部署共享數(shù)據(jù)庫,但各自有略微不同的外向API(outward facing API)。


現(xiàn)在,新的backend-v1和tm-ui-v2組件已經(jīng)部署到生產(chǎn)環(huán)境中。現(xiàn)在是時候把注意力放在一個簡單而又重要的事實(shí)上:生產(chǎn)環(huán)境部署發(fā)生了改變,但是它們還沒有發(fā)布。在turblabs.io?[36]一些優(yōu)秀的博客更詳細(xì)地闡述了這一點(diǎn)[37]?,F(xiàn)在,我們有機(jī)會部署一個非正式的灰度發(fā)布。也許我們希望這個部署慢慢來,首先面向內(nèi)部用戶,或者先對某個特定區(qū)域內(nèi),特定設(shè)備的部分用戶進(jìn)行部署等等。



既然已經(jīng)有了Istio,接下來看看它能做些什么。我們只想為內(nèi)部用戶做一個灰度發(fā)布。我們可以用各種方式來識別內(nèi)部用戶,諸如headers、IP等等,在本例中,如果HTTP header帶有 x-dark-launch: v2 這樣的文本內(nèi)容,則該請求將會被路由到新的backend-v1和tm -ui-v2服務(wù)中。以下是istio路由規(guī)則的樣子:


apiVersion: config.istio.io/v1alpha2
kind: RouteRule
metadata:
?name: tm-ui-v2-dark-launch
spec:
?destination:
? ?name: tm-ui
?precedence: 10
?match:
? ?request:
? ? ?headers:
? ? ? ?x-dark-launch:
? ? ? ? ?exact: "v2"
?route:
?- labels:
? ? ?version: v2

任意用戶身份登錄主頁時,應(yīng)該可以看到當(dāng)前的部署(即指向ticket-monster單體應(yīng)用的tm-ui-v1):


現(xiàn)在,如果改變?yōu)g覽器中的消息頭(例如使用Firefox的修改消息頭工具或其他類似工具),我們應(yīng)該被路由到已灰度上線的服務(wù)(指向backend-v1的tm-ui-v2):



然后點(diǎn)擊“開始”開始修改消息頭并刷新頁面:



現(xiàn)在,我們已經(jīng)被重定向到服務(wù)的灰度發(fā)布版本。由此,可以通過做一個金絲雀發(fā)布(這里也許引1%的實(shí)時流量到新部署),來向客戶群發(fā)布,同時,如果沒有負(fù)面效果的話,那么就緩慢增加流量負(fù)載(5%、10%、50%等)。以下是Istio路由規(guī)則的一個例子,其將v2流量以1%進(jìn)行金絲雀發(fā)布:


 apiVersion: config.istio.io/v1alpha2
kind: RouteRule
metadata:
?name: tm-ui-v2-1pct-canary
spec:
?destination:
? ?name: tm-ui
?precedence: 20
?route:
?- labels:
? ? ?version: v1
? ?weight: 99
?- labels:
? ? ?version: v2
? ?weight: 1

能“看到”或“觀察”這個版本的影響是至關(guān)重要的,稍后我們會進(jìn)一步討論。另外請注意,這種金絲雀發(fā)布方式目前正在架構(gòu)外圍完成,但是也可以通過istio控制內(nèi)部服務(wù)間通訊/交互時采用金絲雀的方式。在接下來的幾個步驟中,我們將開始看到。



五、引入新服務(wù)


回顧下注意事項(xiàng)

  • 我們要關(guān)注被抽取的服務(wù)的API設(shè)計(jì)或邊界?

  • 可能需要重寫單體式應(yīng)用中的某些內(nèi)容?

  • 在確定API后,將為該服務(wù)實(shí)施一個簡單的腳手架或者place holder?

  • 新的Orders服務(wù)將擁有自己的數(shù)據(jù)庫?

  • 新Orders服務(wù)目前不會承擔(dān)任何流量


在這一步中,我們開始設(shè)計(jì)我們所設(shè)想的新訂單服務(wù)的API,在做一些領(lǐng)域驅(qū)動設(shè)計(jì)練習(xí)時,我們常常需要確定一些邊界(boundaries),新的API應(yīng)該更多的與這種邊界相一致。這里可以使用API建模工具來設(shè)計(jì)API,部署一個虛擬化的實(shí)施,并且隨服務(wù)消費(fèi)者的需求變化 一起迭代,而不是一開始花費(fèi)大量的精力去構(gòu)建,最后又發(fā)現(xiàn)需要不斷修改。


在TicketMonster重構(gòu)時,需要在單體應(yīng)用中保留一個上文所說的API,以便在最初的服務(wù)拆分時盡可能輕松并且降低風(fēng)險。無論是哪種情況,有兩個給力的工具可以幫到我們:一個是網(wǎng)頁式的API設(shè)計(jì)器,apicur.io[38],一個是測試/ API虛擬化工具,Hoverfly[39]。Hoverlfy是模擬API或捕獲現(xiàn)有API流量的好工具,可以用來模擬mock端點(diǎn)。


如果我們正在構(gòu)建一個新的API,或在使用領(lǐng)域驅(qū)動設(shè)計(jì)方法后,想看看API什么樣,可以使用apicur.io工具建立一個Swagger/Open API的規(guī)范。



在TicketMonster這個例子中,我們通過在代理模式下啟動hoverfly,并使用hoverfly捕獲從應(yīng)用程序到后端服務(wù)的流量。我們可以在瀏覽器設(shè)置中設(shè)置HTTP代理,從而通過hoverfly發(fā)送所有流量。這將把每個請求/響應(yīng)對(request/response pair)的仿真存儲在JSON文件中。這樣我們就可以在Mock里使用這些請求/響應(yīng)對,或者更進(jìn)一步,用它們開始編寫測試,以規(guī)范具體的實(shí)現(xiàn)代碼中的一些行為。


對于所關(guān)注的請求或響應(yīng)對(response pairs),我們可以生成一個JSON架構(gòu)并用于測試中,參見https://jsonschema.net/#/editor。


例如,結(jié)合使用Rest Assured和Hoverfly,可以調(diào)用hoverfly模擬,并確定該響應(yīng)符合我們預(yù)期的JSON架構(gòu):


@Test
public void testRestEventsSimulation(){
? ?get("/rest/events").then().assertThat().body(matchesJsonSchemaInClasspath("json-schema/rest-events.json"));
}

在新的訂單服務(wù)中,可以查看HoverflyTest.java?[40]測試。有關(guān)測試Java微服務(wù)的更多信息,請查閱Manning這本給力的書,《測試Java微服務(wù)》[41],我的一些同事Alex Soto Bueno[42]、Jason Porter[43]Andy Gumbrecht[44]也參與了這本書的撰寫。


由于這篇博文已經(jīng)很長了,我決定將最后的部分單獨(dú)寫成本主題的第三部分,其中將涉及在單體應(yīng)用和微服務(wù)之間管理數(shù)據(jù)、服務(wù)消費(fèi)的契約測試(consumer contract testing), 功能發(fā)布控制( feature flagging),甚至更復(fù)雜的istio路由等內(nèi)容。本系列的第四部分將展示一個包含上述內(nèi)容的實(shí)操Demo,使用負(fù)載仿真測試(load simulation tests)和故障注入(fault injections)。歡迎訪問我的網(wǎng)站?[45]和關(guān)注我的Twitter?[46]。


原文鏈接:http://blog.christianposta.com/microservices/low-risk-monolith-to-microservice-evolution-part-ii/


參考地址:

[0]?http://blog.christianposta.com/microservices/when-not-to-do-microservices/

[1] https://projects.spring.io/spring-boot/

[2]?http://wildfly.org/

[3]?http://wildfly-swarm.io/

[4]?http://www.apicur.io/

[5]?https://github.com/teiid/teiid-spring-boot

[6]?http://debezium.io/

[7]?http://camel.apache.org/

[8]?https://istio.io/

[9]?http://www.liquibase.org/

[10]?https://ff4j.org/

[11]?https://kubernetes.io/

[12]?https://www.openshift.org/

[13]?https://fabric8.io/

[14]?http://arquillian.org/

[15] https://github.com/pact-foundation/pact-specification

[16] http://arquillian.org/arquillian-algeron/

[17] https://hoverfly.io/

[18] https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-testing.html

[19] http://rest-assured.io/

[20] http://arquillian.org/arquillian-cube/

[21] http://blog.christianposta.com/microservices/low-risk-monolith-to-microservice-evolution/

[22] https://www.amazon.com/Working-Effectively-Legacy-Michael-Feathers/dp/0131177052

[23] https://github.com/ticket-monster-msa/monolith/blob/master/monolith/src/test/java/org/jboss/examples/ticketmonster/test/rest/BookingServiceTest.java

[24] https://maven.fabric8.io/

[25] https://www.openshift.com/

[26] https://openshift.io/

[27] https://www.eclipse.org/che/

[28] http://blog.christianposta.com/deploy/blue-green-deployments-a-b-testing-and-canary-releases/

[29] https://github.com/ticket-monster-msa/monolith/tree/master/tm-ui-v1

[30] https://istio.io/

[31] http://blog.christianposta.com/microservices/00-microservices-patterns-with-envoy-proxy-series/

[32] http://blog.christianposta.com/microservices/application-network-functions-with-esbs-api-management-and-now-service-mesh/

[33] https://medium.com/@mattklein123/service-mesh->[34] https://istio.io/docs/tasks/traffic-management/ingress.html

[35] https://istio.io/docs/reference/config/traffic-rules/routing-rules.html

[36] https://www.turbinelabs.io/

[37] https://blog.turbinelabs.io/deploy-not-equal-release-part-one-4724bc1e726b

[38] http://www.apicur.io/

[39] https://hoverfly.io/

[40] https://github.com/ticket-monster-msa/monolith/blob/master/orders-service/src/test/java/org/ticketmonster/orders/HoverflyTest.java

[41] https://www.manning.com/books/testing-java-microservices

[42] https://twitter.com/alexsotob

[43] https://twitter.com/lightguardjp

[44] https://twitter.com/andygeede?lang=en

[45] http://blog.christianposta.com/

[46] https://twitter.com/christianposta


關(guān)于EAWorld微服務(wù),DevOps,數(shù)據(jù)治理,移動架構(gòu)原創(chuàng)技術(shù)分享,長按二維碼關(guān)注


閱讀原文:http://mp.weixin.qq.com/s?timestamp=1512378337&src=3&ver=1&signature=ejHmnBOYMxw4HemjtuMNeNnz15OA*JUKO9q32lQhYu2CjlSJm9QTrSxHwz3gpd-PVpYj82H-PPyUXV4mJrlQlOicsJdXVzn361tVw-ehYPDTrZ*jndTYSDd7e8o4mcmDlHmomGhr0rzfneefZjdkqNLlA1wmgQgMrDPj*Zfsptc=&devicetype=Windows-QQBrowser&version=61030004&pass_ticket=qMx7ntinAtmqhVn+C23mCuwc9ZRyUp20kIusGgbFLi0=&uin=MTc1MDA1NjU1&ascene=1
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容