后端項(xiàng)目CICD流程構(gòu)建

原文地址

原文鏈接

前言

本流程的設(shè)計(jì)目的是針對(duì)服務(wù)器資源并不是非常充分的個(gè)人開(kāi)發(fā)小伙伴,設(shè)計(jì)一套完全自動(dòng)編譯打包發(fā)布的流程。部署方面肯定是使用容器進(jìn)行部署,比較方便管理,容器管理服務(wù)使用portainer進(jìn)行管理,之前使用的是rancher,但是后來(lái)rancher主做k8s的管理對(duì)于獨(dú)立容器的管理沒(méi)有早先版本那么易用就放棄了,故而選擇portainer。其他的產(chǎn)品也調(diào)研過(guò),但是整體來(lái)說(shuō)portainer是目前階段最好的選擇。

編譯位置選擇gitlab直接編譯的選項(xiàng),不使用在服務(wù)器拉取代碼編譯的原因是希望服務(wù)器配置盡可能少。因?yàn)楸局苁【褪〉脑瓌t小伙伴使用的基本都是輕量級(jí)服務(wù)器,那么服務(wù)器遷移就是很有可能會(huì)出現(xiàn)的事情,我們需要最快速最簡(jiǎn)單的搭建起一個(gè)可用服務(wù)器,所以希望服務(wù)器本身的配置越少越好。而不使用容器編譯的原因是為了減少容器體積,容器編譯需要使用maven+jdk或者gradle+jdk的容器,這種容器的的體積相比于jre可多太多了(順帶說(shuō)一句JAVA17沒(méi)有官方JRE的鏡像,只能使用精簡(jiǎn)版本的JDK代替,但是也遠(yuǎn)遠(yuǎn)比mavne+jdk這種容器小的多)。使用gitlab是個(gè)人習(xí)慣,也可用使用其他倉(cāng)庫(kù)操作,步驟大同小異

所以我們的自動(dòng)編譯打包發(fā)布流程就是首先gitlab上傳代碼,走CI流程直接編譯生成制品,然后使用webhook調(diào)用portainer接口,portainer收到調(diào)用使用gitlab的密鑰拉去gitlab制品發(fā)布

portainer構(gòu)建

portainer有兩個(gè)版本,社區(qū)版本和商業(yè)版本。社區(qū)版本雖然有功能閹割,但是目前來(lái)說(shuō)閹割的功能不影響使用。商業(yè)版本可以在官網(wǎng)申請(qǐng),可以免費(fèi)試用5個(gè)節(jié)點(diǎn),下發(fā)一個(gè)3年的證書(shū),需要填寫(xiě)一些信息和同意一些協(xié)議,由于是個(gè)人開(kāi)發(fā)使用沒(méi)太注意協(xié)議,如果是企業(yè)使用建議看看。我這里使用的是商業(yè)版本,主要是商業(yè)版帥,而且沒(méi)有社區(qū)版的升級(jí)提示頁(yè)面比較簡(jiǎn)潔,大家按需選擇即可

這里給出的官方文檔和我這里的docker-compose,如果有細(xì)節(jié)問(wèn)題可以查閱官方文檔

#這里新建了portainer_portainer_data的數(shù)據(jù)卷,當(dāng)然也可以使用指定文件夾作為數(shù)據(jù)卷,但是如果不是設(shè)置官方的portainer_data作為數(shù)據(jù)卷的,那么在一些官方的文檔中的某些命令可能會(huì)有問(wèn)題(比如重置密碼)需要稍作修改,所以盡量使用portainer_data作為數(shù)據(jù)卷,我這里也不標(biāo)準(zhǔn)
version: "3"
services:
  portainer:
    image: 'portainer/portainer-ee:latest'
    restart: always
    container_name: 'portainer'
    hostname: 'portainer'
    privileged: true
    ports:
      - '8000:8000'
      - '9443:9443'
      - '9000:9000'
    privileged: true
    volumes:
      - portainer_data:/data
      - /var/run/docker.sock:/var/run/docker.sock
volumes:
  portainer_data:

構(gòu)建完成設(shè)置密碼添加棧、容器、用戶(hù)一類(lèi)的基本多點(diǎn)點(diǎn)基本就會(huì)了,如果是商業(yè)版本的將他發(fā)到你郵箱的密碼在進(jìn)入時(shí)輸入激活即可。關(guān)于portainer端口的nginx代理,包括在此之前的域名解析,和portainer子域名的申請(qǐng)以及https證書(shū)的申請(qǐng)等等和本篇沒(méi)有關(guān)系就不贅言了

GitLab構(gòu)建

我們這里就不在自己服務(wù)器上搭建gitlab了,gitlab的資源消耗太大,而且公共的gitlab的官網(wǎng)的也挺好的,如果是自己玩的話(huà)完全不需要自己搭建gitlab。我們這里就是主要介紹項(xiàng)目完成自動(dòng)構(gòu)建發(fā)布需要的文件應(yīng)該怎么寫(xiě),我們需要gitlab-ci.yml來(lái)控制ci的打包命令以及docker-compose.yml作為portainer的構(gòu)建方法以及Dockerfile讓portainer做實(shí)際構(gòu)建,我們從后往前推導(dǎo)首先是Dockerfile

Dockerfile

在執(zhí)行到Dockerfile的時(shí)候,我們?cè)趃itlab的jar包應(yīng)該已經(jīng)打包完成了,所以我們需要使用請(qǐng)求gitlab來(lái)獲取已經(jīng)打好的jar包,翻閱gitlab文檔可知我們需要3個(gè)參數(shù)可以從公網(wǎng)上拿到j(luò)ar包,分別是訪(fǎng)問(wèn)令牌項(xiàng)目id以及作業(yè)id,故而如果我們能在參數(shù)中拿到這三個(gè)值,我們只需要在容器中通過(guò)文檔的url拿到制品壓縮包,解壓縮獲得jar包,然后直接部署就可以了

最終Dockerfile文件如下,

FROM  astercass/jdk17-simple:latest
LABEL author="astercass@qq.com"
WORKDIR /usr/src/apps
ARG READ_TOKEN
ARG CI_PROJECT_ID
ARG CI_JOB_ID
RUN curl --location --output artifacts.zip  \
    --header PRIVATE-TOKEN:$READ_TOKEN  \
    "https://gitlab.com/api/v4/projects/$CI_PROJECT_ID/jobs/$CI_JOB_ID/artifacts" \
    && unzip artifacts.zip && mv ./target/*.jar /usr/src/apps/applicationA.jar
ENTRYPOINT ["java","-jar","/usr/src/apps/applicationA.jar","--logging.path=/usr/src/apps/log"]

astercass/jdk17-simple:latest這個(gè)鏡像是bellsoft/liberica-openjdk-alpine:17添加curl命令(apk add curl)得來(lái)到的,由于我們Dockerfile文件中必須使用curl來(lái)獲取公網(wǎng)包,但是該鏡像并沒(méi)有安裝,為了快速構(gòu)建鏡像所以我補(bǔ)充了curl命令并上傳了astercass/jdk17-simple:latest。使用原鏡像并添加apk add curl效果一樣,另外提一句liberica-openjdk-alpine是spring推薦【W(wǎng)e recommend BellSoft Liberica JDK version 17.】可以放心使用

docker-compose.yml

docker-compose和Dockerfile一樣,實(shí)際的執(zhí)行機(jī)器是我們自己的服務(wù)器,是由portainer執(zhí)行的。由于是從docker-compose執(zhí)行的Dockerfile,那么所需要的3個(gè)參數(shù)自然也需要我們這邊傳入了,查閱portainer的文檔可知我們可以在網(wǎng)絡(luò)鉤子webhook中攜帶參數(shù),那么gitlab作為調(diào)用方只需要要傳入這三個(gè)參數(shù),那么就可以讓我們的容器拿到gitlab打好的包了

最終docker-compose.yml文件如下

version: "3.9"
services:
  applicationA:
    build:
      context: ./
      dockerfile: Dockerfile
      args:
        READ_TOKEN: ${READ_TOKEN}
        CI_PROJECT_ID: ${CI_PROJECT_ID}
        CI_JOB_ID: ${CI_JOB_ID}
    pull_policy: build
    hostname: 'application_a'
    container_name: 'application_a'
    restart: always

docker-compose.yml中pull_policy這個(gè)屬性設(shè)置為build非常重要,這個(gè)是portainer可以更新的關(guān)鍵

gitlab-ci.yml

從上文可知,我們這里需要提供訪(fǎng)問(wèn)令牌項(xiàng)目id以及作業(yè)id三個(gè)信息給到portainer,當(dāng)然還需要portainer的webhook地址。訪(fǎng)問(wèn)令牌是固定的,我們先在gitlab的個(gè)人設(shè)置中生成令牌,等到之后我們?cè)趐ortainer建立stack的時(shí)候直接作為環(huán)境變量寫(xiě)入即可。項(xiàng)目id和作業(yè)id在ci的時(shí)候可以通過(guò)gitlab的預(yù)設(shè)環(huán)境變量CI_PROJECT_IDCI_JOB_ID獲取。portainer的webhook的地址,我們之后在stack的步驟會(huì)配置在自定義的環(huán)境變量中,我們首先假定配置的環(huán)境變量為PORTAINER_APPLICATION_A_WEB_HOOK

此時(shí)在ci文件中我們可以通過(guò)PORTAINER_APPLICATION_A_WEB_HOOK**拿到鉤子地址,然后將**CI_PROJECT_ID$CI_JOB_ID作為參數(shù)即可

我們將ci分為兩個(gè)階段,打包階段和發(fā)布階段,在打包階段直接使用mvn命令打包生成制品,同時(shí)將本階段的作業(yè)id暫且存下。在發(fā)布階段,將本項(xiàng)目id和上一個(gè)階段的作業(yè)id作為參數(shù)調(diào)用鉤子地址完成發(fā)布調(diào)用,這里給出我的文件代碼

image: maven:3.8.5-openjdk-17
#image: alpine:latest #use for test
stages:
  - build
  - release

#print variable and version
before_script:
  - export APPLICATION_VERSION=`mvn spring-boot:build-info | grep Building | awk '{print $4}'`
  - echo ${APPLICATION_VERSION}
  - export TAG=${CI_COMMIT_BRANCH}_${CI_COMMIT_SHA:0:8}_${APPLICATION_VERSION}
  - echo ${TAG}
  - echo ${CI_PROJECT_ID}
  - echo ${CI_JOB_ID}
  #gitlab current variables
  - echo ${PORTAINER_APPLICATION_A_WEB_HOOK}

#build
build:
  stage: build
  script:
    - mvn clean package
    - export PARAM_VAR="CI_PROJECT_ID=${CI_PROJECT_ID}&CI_JOB_ID=${CI_JOB_ID}"
    - echo "PARAM_VAR=${PARAM_VAR}" >> build.env
    - cat build.env
  except:
    - tags
  allow_failure: false
  artifacts:
    reports:
      dotenv: build.env
    paths:
      - target/*.jar
    expire_in: 1 week

#deploy
release:
  stage: release
  script:
    - echo ${PORTAINER_APPLICATION_A_WEB_HOOK}?${PARAM_VAR}
    - curl -X POST "${PORTAINER_APPLICATION_A_WEB_HOOK}?${PARAM_VAR}"
  dependencies:
    - build

Stack構(gòu)建

進(jìn)入portainer,選擇環(huán)境后,進(jìn)入Stacks欄,點(diǎn)擊Add stack,使用Repository的構(gòu)建方法,Name隨意,gitlab需要權(quán)限驗(yàn)證所以需要開(kāi)啟Authentication,Username隨意,Personal Access Token需要在gitlab的個(gè)人設(shè)置中生成令牌,注意這里的令牌可以通過(guò)查詢(xún)Docker的構(gòu)建歷史獲得,如果需要的話(huà)注意權(quán)限問(wèn)題,Repository URL和Repository reference以及Compose path按具體的項(xiàng)目要求填寫(xiě),下面兩個(gè)就比較重要了

首先開(kāi)啟Automatic updates,選擇webhook,復(fù)制值,在gitlab的項(xiàng)目或者組的【設(shè)置】【CI/CD】中作為環(huán)境變量的VALUE,變量的KEY即為我們上面提到的PORTAINER_APPLICATION_A_WEB_HOOK。當(dāng)然你這里也可以選擇輪詢(xún)的方式去獲取最新的代碼,但是既然網(wǎng)絡(luò)鉤子的方式輪詢(xún)?nèi)ダ喽嗌偕倏雌饋?lái)不那么優(yōu)雅

然后是Environment variables,這里我們需要手動(dòng)添加一個(gè)環(huán)境變量,就是剛才填在Personal Access Token欄目中的令牌,按照上文中Dockerfile和docker-compose.yml中的命名我們將變量的KEY命名為READ_TOKEN,而VALUE則是該令牌的值,還是那句話(huà),有需要的話(huà)注意權(quán)限問(wèn)題。還有另外兩個(gè)參數(shù)CI_PROJECT_ID和CI_JOB_ID不用填寫(xiě),會(huì)從webhook中帶過(guò)來(lái),按下deploy the stack如果成功的話(huà)這里就可以看到三個(gè)參數(shù)了

補(bǔ)充

  • portainer版本,社區(qū)版升級(jí)到商業(yè)版本商業(yè)版降級(jí)到社區(qū)版本

  • 本文檔細(xì)節(jié)之處大家自行優(yōu)化,比如在Dockerfile文件中沒(méi)有指定yml配置環(huán)境,在ci文件中沒(méi)有區(qū)別分支等

  • gitlab的個(gè)人的令牌可以在服務(wù)器中使用docker history的方式查看構(gòu)建歷史,譬如

    docker history --format "{{.CreatedBy}}" d632af1b1136 --no-trunc
    

    所以個(gè)人令牌以參數(shù)傳入的方式是可以被服務(wù)器其他用戶(hù)看到的,故而如果需要注意令牌的等級(jí),或者換低等級(jí)的用戶(hù)創(chuàng)建令牌

  • 為什么CI_PROJECT_ID和CI_JOB_ID直接從webhook中作為參數(shù)帶過(guò)來(lái),而READ_TOKEN需要我們?cè)趐ortainer手動(dòng)創(chuàng)建呢,也直接在gitlab-ci調(diào)用webhook時(shí)作為參數(shù)傳進(jìn)來(lái)不就行了嗎?

    這是因?yàn)樵趃itlab-ci中,我們需要將生成制品和調(diào)用webhook分為兩個(gè)階段,否則制品還沒(méi)生成,直接調(diào)用的話(huà)拿不到制品就很傻了,而且分為build和release兩個(gè)階段看起來(lái)也更優(yōu)雅一些

    那么既然是兩個(gè)階段它們的JOB_ID就是不同的,也就是說(shuō)其實(shí)在release階段,需要向portainer傳遞的JOB_ID是build階段的JOB_ID,所以就涉及到不同階段參數(shù)傳遞的問(wèn)題,這里我們使用官方推薦的方式通過(guò)傳遞環(huán)境變量完成參數(shù)傳遞

    由于個(gè)人令牌在gitlab-ci中自動(dòng)會(huì)被隱藏,所有的打印都會(huì)變成glpat-[MASKED],只是在打印的時(shí)候是這樣實(shí)際使用還是原有值,但是

    echo "$PARAM" >> build.env
    

    這種命令就會(huì)將glpat-[MASKED]一并打入build.env,所以portainer收到的個(gè)人令牌就真的變成了"glpat-[MASKED]",其實(shí)這個(gè)問(wèn)題也好解決無(wú)論是在設(shè)置中將這個(gè)變量設(shè)置為不隱藏,還是只使用glpat-后面的部分作為變量之后再拼接都可以繞過(guò)校驗(yàn),但總覺(jué)一方面不太優(yōu)雅另一方面有違gitlab的基本規(guī)則,故而將個(gè)人令牌使用在portainer中直接寫(xiě)入,其他變量由gitlab通過(guò)webhook傳遞的方式完成功能

  • 如果使用每次都拉去代碼重構(gòu)鏡像的方式,會(huì)導(dǎo)致冗余無(wú)用鏡像過(guò)多,尤其是在頻繁更新的時(shí)候,所以我們需要使用cron去定期清除無(wú)用鏡像核心命令是docker image prune -a --filter="label=need_filtter_label" -f具體的腳本文件和cron配置不屬于本篇內(nèi)容,不再贅述

一些坑

  • 如果在構(gòu)建portainer時(shí)候不是設(shè)置官方的portainer_data作為數(shù)據(jù)卷的,那么在一些官方的文檔中的某些命令可能會(huì)有問(wèn)題(比如重置密碼)需要稍作修改,所以盡量使用portainer_data作為數(shù)據(jù)卷

  • 構(gòu)建portainer使用docker-compose的方式一定要使用volumes:這個(gè)屬性創(chuàng)建卷,如果直接使用本地目錄作為對(duì)應(yīng)數(shù)據(jù)卷,portainer將無(wú)法連接到本地服務(wù),可能還會(huì)其他問(wèn)題,所以不要直接使用本地目錄作為對(duì)應(yīng)數(shù)據(jù)卷

  • docker-compose.yml中pull_policy這個(gè)屬性設(shè)置為build非常重要,這個(gè)是portainer可以更新的關(guān)鍵。如果沒(méi)有這個(gè)參數(shù),那么portainer每次都只會(huì)拉本地鏡像,完全不會(huì)重新使用Dockerfile構(gòu)建,只有在刪除本地鏡像后才會(huì)重新構(gòu)建,非常雞肋。我一度都想換個(gè)方式,portainer不使用gitRepository的方式構(gòu)建stack而是使用鏡像的方式,通過(guò)gitlab上傳已經(jīng)帶有jar包的鏡像到dockerhub私有倉(cāng)庫(kù),然后portainer直接拉鏡像構(gòu)建。終于我發(fā)現(xiàn)pull_policy這個(gè)屬性,可以看到在22年4月初的時(shí)候,portainer的貢獻(xiàn)者還在說(shuō)We would also very much like to use this feature. 【這個(gè)功能是 stack "full image rebuild" feature implemented on portainer UI as well.】幸運(yùn)的是在4月末有人提出可以使用pull_policy屬性解決,這里列出pull_policy具體的相關(guān)的官方文檔

  • 我這邊調(diào)研的時(shí)候是最后再細(xì)看的portainer的webhook文檔,在此之前只是大概瀏覽了一些知道方案可行,所以一開(kāi)始我并不知道portainer的webhook可以帶參數(shù),所以花了大量的時(shí)間在如何讓portainer可以獲得項(xiàng)目id和作業(yè)id上,姑且記錄下以防之后用到

    我這里原本的考慮是,如果我們把項(xiàng)目id和作業(yè)id放在公網(wǎng)的某個(gè)地方,通過(guò)某個(gè)密鑰訪(fǎng)問(wèn),在ci的時(shí)候每次去更新這個(gè)id,然后portainer收到調(diào)用后用密鑰去訪(fǎng)問(wèn)獲取項(xiàng)目id和作業(yè)id(其實(shí)項(xiàng)目id原理上可以寫(xiě)死在portainer的stack中,因?yàn)榉凑膊粫?huì)變,但是考慮到如果是利用腳手架開(kāi)新項(xiàng)目的話(huà),腳手架名稱(chēng)可以提醒開(kāi)發(fā)更換為本項(xiàng)目名稱(chēng),但是如果放一串?dāng)?shù)字在那里會(huì)增加開(kāi)發(fā)的理解成本,故而我們需要將項(xiàng)目id也做同樣處理)

    考慮到既然portainer可以拿到gitlab的個(gè)人令牌,那么不妨直接使用gitlab的個(gè)人令牌作為密鑰,將項(xiàng)目id和作業(yè)id配成gitlab的環(huán)境變量就可以完成這個(gè)功能了,調(diào)研后發(fā)現(xiàn)可行,故而我們需要在gitlab-ci中執(zhí)行插入/更新參數(shù)的邏輯。如果是首次ci,沒(méi)有該變量執(zhí)行插入邏輯,那么它的返回為

    {"variable_type":"env_var","key":"KEY_XXX","value":"VALUE_YYY","protected":false,"masked":false,"raw":false,"environment_scope":"*"}
    

    如果已有變量則插入邏輯后它的返回為

    {"message":{"key":["(KEY_XXX)已被使用"]}}
    

    我們則需要再執(zhí)行更新邏輯,可以根據(jù)是否含有"(KEY_XXX)"字符串來(lái)判斷是否需要調(diào)用更新邏輯,添加的部分代碼如下

    before_script:
      #這里我們需要使用【項(xiàng)目名稱(chēng)】作為參數(shù)去定義需要傳遞的【項(xiàng)目id】和【作業(yè)id】
      - export PROJECT_WEB_ID=${CI_PROJECT_NAME}_PROJECT_WEB_ID
      - echo ${PROJECT_WEB_ID}
      - export PROJECT_BRANCH_LAST_JOB_ID=${CI_PROJECT_NAME}_${CI_COMMIT_BRANCH}_LAST_JOB_ID
      - echo ${PROJECT_BRANCH_LAST_JOB_ID}
      #這里由于我們是使用的是【組環(huán)境變量】所以需要將組id定義到組環(huán)境變量中,當(dāng)然還有個(gè)人令牌,這個(gè)個(gè)人令牌和上文的不同,需要有寫(xiě)權(quán)限的
      - echo ${ACCESS_ADMIN_TOKEN}
      - echo ${CI_GROUP_ID}
    
    build:
      stage: build
      script:
        - mvn clean package
        #project id in web
        - export INSERT_PROJECT_ID_RESULT=`curl --request POST --header "PRIVATE-TOKEN:${ACCESS_ADMIN_TOKEN}"
          "https://gitlab.com/api/v4/groups/${CI_GROUP_ID}/variables"
          --form "key=${PROJECT_WEB_ID}" --form "value=${CI_PROJECT_ID}"`
        - echo $INSERT_PROJECT_ID_RESULT
        # update project id
        - >
          if [[ "$INSERT_PROJECT_ID_RESULT" =~ "($PROJECT_WEB_ID)" ]]; then 
            curl --request PUT --header "PRIVATE-TOKEN:${ACCESS_ADMIN_TOKEN}" \
            "https://gitlab.com/api/v4/groups/${CI_GROUP_ID}/variables/${PROJECT_WEB_ID}" \
            --form "value=${CI_PROJECT_ID}"
          fi
        #job id in web
        - export INSERT_JOB_ID_RESULT=`curl --request POST --header "PRIVATE-TOKEN:${ACCESS_ADMIN_TOKEN}"
          "https://gitlab.com/api/v4/groups/${CI_GROUP_ID}/variables"
          --form "key=${PROJECT_BRANCH_LAST_JOB_ID}" --form "value=${CI_JOB_ID}"`
        - echo $INSERT_JOB_ID_RESULT
        # update job id
        - >
          if [[ "$INSERT_JOB_ID_RESULT" =~ "($PROJECT_BRANCH_LAST_JOB_ID)" ]]; then 
            curl --request PUT --header "PRIVATE-TOKEN:${ACCESS_ADMIN_TOKEN}" \
            "https://gitlab.com/api/v4/groups/${CI_GROUP_ID}/variables/${PROJECT_BRANCH_LAST_JOB_ID}" \
            --form "value=${CI_JOB_ID}"
          fi
    

    作為接收方portainer的Dockerfile文件當(dāng)然也需要做更改,核心代碼如下

    ARG ACCESS_ADMIN_TOKEN
    ARG CI_GROUP_ID
    ARG DEP_ENV_NAME
    RUN THIS_PROJECT_ID=`curl --header PRIVATE-TOKEN:$ACCESS_ADMIN_TOKEN \
         "https://gitlab.com/api/v4/groups/$CI_GROUP_ID/variables/application_a_PROJECT_WEB_ID" \
        | sed -e 's/.*value\":\"\([0-9]\+\).*/\1/g'` && \
        THIS_CHANNEL_ID=`curl --header PRIVATE-TOKEN:$ACCESS_ADMIN_TOKEN  \
        "https://gitlab.com/api/v4/groups/$CI_GROUP_ID/variables/application_a_$DEP_ENV_NAME_LAST_JOB_ID"  \
        | sed -e 's/.*value\":\"\([0-9]\+\).*/\1/g'` &&  \
        curl --location --output artifacts.zip  \
        --header PRIVATE-TOKEN:$ACCESS_ADMIN_TOKEN  \
        "https://gitlab.com/api/v4/projects/$THIS_PROJECT_ID/jobs/$THIS_CHANNEL_ID/artifacts" \
        && unzip artifacts.zip && mv ./target/*.jar /usr/src/appw/application_a.jar
    

    ACCESS_ADMIN_TOKEN和CI_GROUP_ID都是不變量可以寫(xiě)死在protainer的stack中,選用group環(huán)境變量的原因就是group基本作為一套項(xiàng)目的CICD配置是不會(huì)變的,如果有其他項(xiàng)目組需要?jiǎng)t需要取用新的groupId

原文地址

原文鏈接

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

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

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