基於Docker Compose的.NET Core微服務持續發布

是不是現在每個團隊都需要上K8s才夠潮流,不用K8s是不是就落伍了。今天,我就通過這篇文章來回答一下。

一、先給出我的看法和建議

我想說的是,對於很多的微小團隊來說,可能都不是一定要上K8s,畢竟上K8s也是需要成本和人力的。對像我司一樣的傳統企業做数字化轉型的信息團隊來講,人數不多,沒有專門的Ops人員,領導又想要儘快迭代支持公司業務發展,而且關鍵還要節省成本(內心想法是:WTF)。

在此之下,信息團隊需要綜合引入先進技術帶來的價值以及需要承擔的成本和風險。任何架構的產生,都會解決一定的問題,但是同樣也會引入新的複雜度,正如微服務架構風格,看着香實際吃着才知道需要承受很多的“苦”(比如數據一致性又比如服務的治理等等)。

因此,結合考慮下來,我的建議是開發測試環境使用Docker Compose進行容器編排即可,而UAT或生產環境則建議使用雲廠商的K8s服務(比如阿里雲ACK服務)而不選擇自建K8s集群

那麼,今天就跟大家介紹一下如何使用Docker Compose這個輕量級的編排工具實現.NET Core微服務的持續發布。

二、Docker Compose

Docker主要用來運行單容器應用,而Docker Compose則是一個用來定義和應用多容器應用的工具,如下圖所示:

使用Docker Compose,我們可以將多容器的定義和部署方式定義在一個yml文件中,這種方式特別是微服務這種架構風格,可以將多個微服務的定義及部署都規範在一個yml文件中,然後一鍵部署、啟動或銷毀整個微服務應用。所有的一切操作,只需要下面的一句話:

$docker-compose up

Compose 的安裝請參考:https://docs.docker.com/compose/install/#install-compose,這裏就不再贅述,它不是本文重點。

安裝后驗證:

$docker-compose --version
docker-compose version 1.25.1, build a82fef07

三、一個簡單的發布流程示例

本文演示示例的流程大概會如下圖所示:

閱讀過我之前的一篇文章《基於Jenkins Pipeline的ASP.NET Core持續集成實踐》的童鞋應該對這個流程比較熟悉了。這裏,我仍然延續這個流程,作為一個平滑過渡。首先,我們在Jenkins上觸發容器的發布流水線任務,此任務會從Git服務器上拉取指定分支(一般都是測試分支)的最新代碼。

其次,在CI服務器上使用.NET Core SDK執行Build編譯和發布Release文件,基於發布后的Release文件進行鏡像的打包(確保你的項目裏面都有Dockerfile且設置為“始終複製”)。然後,基於打包后的鏡像,將其推送到企業的私有Registry服務器上(即本地鏡像倉庫,可以基於Harbor搭建一個,也可以直接用Docker Registry搭建一個,不建議使用docker hub的公有庫,如何搭建私有鏡像倉庫可以參考我的這一篇文章:《Docker常用流行鏡像倉庫的搭建》)。

最後,在測試服務器或要運行容器的服務器上執行docker compose up完成容器的版本更新。當然,也可以直接在docker-compose.yml文件內設置編譯路徑完成編譯和發布的操作(Dockerfile裏面定義進行Build和Publish)。這裏目的在於讓實例更簡單,且能讓初學者更容易理解,於是我就分開了。

四、.NET Core微服務發布示例

微服務示例準備

假設我們有一堆使用ASP.NET Core開發的微服務,這些微服務主要是為了實現諸如API網關、Identity鑒權、Notification通知、Job中心等基礎設施服務,因此我們將他們整合在一起進行持續集成和部署。

這裏為了讓示例盡可能簡單,每個微服務的Dockerfile只有以下幾句(這裏以一個通知API服務為例):

FROM reg.xdp.xi-life.cn/xdp-service-runtime:2.2
WORKDIR /app
COPY . /app
EXPOSE 80
ENTRYPOINT ["dotnet", "XDP.Core.Notification.API.dll"]

其中這裏的容器鏡像來自於私有鏡像倉庫,是一個封裝過的用於ASP.NET Core Runtime的容器鏡像。當然,上面說過,也可以在Dockerfile裏面進行服務的編譯和發布。

流水線任務腳本

同樣,為了在Jenkins上快速進行微服務的鏡像構建和推送以及部署,我們也需要編寫一個流水線構建任務。

下面是這個示例流水線任務的腳本:

pipeline{
    agent any
    environment {
        API_CODE_BRANCH="*/master"
        SSH_SERVER_NAME_REGISTRY="XDP-REGISTRY-Server"
        SSH_SERVER_NAME_DEV="XDP-DEV-Server"
        SSH_SERVER_NAME_AT="XDP-AT-Server"
        SSH_SERVER_NAME_SIT="XDP-SIT-Server"
    }
    stages {
        stage('XDP Core APIs Checkout & Build') {
            steps{
             checkout([$class: 'GitSCM', branches: [[name: env.API_CODE_BRANCH]], doGenerateSubmoduleConfigurations: false, extensions: [], submoduleCfg: [], userRemoteConfigs: [[credentialsId: '35b9890b-2338-45e2-8a1a-78e9bbe1d3e2', url: 'http://192.168.18.150:3000/XDP.Core/XDP.Core.git']]])
             echo 'Core APIs Dev Branch Checkout Done' 
             bat  '''
               dotnet build XDP.Core-InfraServices.sln
               dotnet publish "%WORKSPACE%\\src\\services\\XDP.Core\\Components\\XDP.Core.ApiGateway\\XDP.Core.ApiGateway.csproj" -o "%WORKSPACE%\\XDP.Core.ApiGateway.API\\publish" --framework netcoreapp2.2
               dotnet publish "%WORKSPACE%\\src\\services\\XDP.Core\\Components\\XDP.Core.ApiGateway.Internal\\XDP.Core.ApiGateway.Internal.csproj" -o "%WORKSPACE%\\XDP.Core.ApiGateway.Internal.API\\publish" --framework netcoreapp2.2
               dotnet publish "%WORKSPACE%\\src\\services\\XDP.Core\\Services\\XDP.Core.Authorization.API\\XDP.Core.Authorization.API.csproj" -o "%WORKSPACE%\\XDP.Core.Authorization.API\\publish" --framework netcoreapp2.2
               dotnet publish "%WORKSPACE%\\src\\services\\XDP.Core\\Services\\XDP.Core.Authorization.Job\\XDP.Core.Authorization.Job.csproj" -o "%WORKSPACE%\\XDP.Core.Authorization.Job\\publish" --framework netcoreapp2.2
               dotnet publish "%WORKSPACE%\\src\\services\\XDP.Core\\Services\\XDP.Core.Identity.API\\XDP.Core.Identity.API.csproj" -o "%WORKSPACE%\\XDP.Core.Identity.API\\publish" --framework netcoreapp2.2
               dotnet publish "%WORKSPACE%\\src\\services\\XDP.Core\\Services\\XDP.Core.Notification.API\\XDP.Core.Notification.API.csproj" -o "%WORKSPACE%\\XDP.Core.Notification.API\\publish" --framework netcoreapp2.2
               dotnet publish "%WORKSPACE%\\src\\services\\XDP.Core\\Services\\XDP.Core.JobCenter\\XDP.Core.JobCenter.csproj" -o "%WORKSPACE%\\XDP.Core.JobCenter.API\\publish" --framework netcoreapp2.2
               '''
             echo 'Core APIs Build & Publish Done'
            }
        }
        stage('XDP API Gateway Docker Image') {
            steps{
                bat '''
                    docker rmi reg.xdp.xi-life.cn/core-apigateway-portal:latest;
                    cd XDP.Core.ApiGateway.API/publish;
                    docker build -t reg.xdp.xi-life.cn/core-apigateway-portal:latest .;
                    docker push reg.xdp.xi-life.cn/core-apigateway-portal:latest;
                '''
                echo 'XDP Portal API Gateway Deploy Done'    

                bat '''
                    docker rmi reg.xdp.xi-life.cn/core-apigateway-internal:latest;
                    cd XDP.Core.ApiGateway.Internal.API/publish;
                    docker build -t reg.xdp.xi-life.cn/core-apigateway-internal:latest .;
                    docker push reg.xdp.xi-life.cn/core-apigateway-internal:latest;
                '''
                echo 'XDP Internal API Gateway Deploy Done'
            }
        }
        stage('Core Identity API Docker Image') {
            steps{
                ......
            }
        }
        stage('Core Authorization Job Docker Image') {
            steps{
                ......
            }
        }
        stage('Core Notification API Docker Image') {
            steps{
                ......  
            }
        }
        stage('Core JobCenter API Docker Image') {
            steps{
                ......     
            }
        }
        stage('Deploy to Local SIT Server') {
            steps{
                sshPublisher(publishers: [sshPublisherDesc(configName: env.SSH_SERVER_NAME_SIT, 
                    transfers: [sshTransfer(cleanRemote: false, excludes: '', 
                    execCommand: '''
                     cd compose/xdp;
                     IMAGE_TAG=latest docker-compose down;
                     docker rmi $(docker images -q)
                     IMAGE_TAG=latest docker-compose up -d;
                    ''', 
                    execTimeout: 120000, flatten: false, makeEmptyDirs: false, 
                    noDefaultExcludes: false, patternSeparator: '[, ]+', 
                    remoteDirectory: 'compose/xdp/', remoteDirectorySDF: false, 
                    removePrefix: '', 
                    sourceFiles: '', 
                    excludeFiles: '')], 
                    usePromotionTimestamp: false, useWorkspaceInPromotion: false, verbose: false)])
                echo 'Deploy to XDP SIT Server Done'     
            }
        }
    }
}

這個腳本我省去了一些重複的內容,只需要了解它的職責即可。

需要注意的地方有幾點:

(1)在進行dotnet build的時候,要明確SDK使用哪個版本,比如因為這裏的示例代碼是基於.NET Core 2.2開發的因此這裏使用的是2.2。如果你使用的是2.1,則標註2.1,如果是3.1,則標註3.1。

(2)在進行docker build的時候,要明確鏡像使用哪個Tag,這裏因為是本地開發測試環境,所以直接簡單暴力的直接使用了latest這個Tag。

(3)在進行sshPublish的時候,要提前將docker-compose.yml配置拷貝到對應的指定目錄下。當然,這一塊建議也將其納入git倉庫進行統一管理和統一發布到不同的環境的指定目錄下。

(4)如果你的Jenkins是裝在Windows Server上,要記住只有Windows Server 2016及以上版本才支持Docker,否則無法直接進行docker的命令行操作。如果低於2016,Windows 10專業版也可以,不過不建議。

擴展點:

是否可以一套docker-compose方案標準化部署到多個測試環境?是可以的,我們可以在Jenkins構建任務中配置Parameters,這樣就可以一次性部署到多個環境。例如,下面的示例中我設置了一個每次發布可以選擇到底要發布到哪個環境,這裡是單選,你也可以設置為多選。

效果如下:

docker-compose.yml

終於來到了compose的重點內容:docker-compose.yml

這裏我給出上面這個示例的yml示例內容(同樣,也省略了重複性的內容):

version: '2'

services:
  core_apigateway_portal:
    image: reg.xdp.xi-life.cn/core-apigateway-portal:${IMAGE_TAG}
    container_name: xdp_core_apigateway_portal
    restart: always
    privileged: true
    mem_limit: 1024m
    memswap_limit: 1024m
    env_file:
      - ../docker-variables.env
    ports:
      - 5000:80
    volumes:
      - /etc/localtime:/etc/localtime

  core_apigateway_internal:
    image: reg.xdp.xi-life.cn/core-apigateway-internal:${IMAGE_TAG}
    container_name: xdp_core_apigateway_internal
    restart: always
    privileged: true
    mem_limit: 1024m
    memswap_limit: 1024m
    env_file:
      - ../docker-variables.env
    ports:
      - 5100:80
    volumes:
      - /etc/localtime:/etc/localtime

  core_identity_api:
    image: reg.xdp.xi-life.cn/core-identity-api:${IMAGE_TAG}
    container_name: xdp_core_identity_api
    restart: always
    privileged: true
    mem_limit: 512m
    memswap_limit: 512m
    env_file:
      - ../docker-variables.env
    ports:
      - 6010:80
    volumes:
      - /etc/localtime:/etc/localtime

  core_authorization_api:
    ......

  core_authorization_job:
    ......

  core_notification_api:
    ......

  core_jobcenter_api:
    ......

  bff_xams_api:
    ......

備註:這裏使用的是version:2的語法,因為3開始不支持內存限制mem_limit等屬性設置。當然,你可以使用3的語法,去掉mem_limit和memswap_limit屬性即可。

這裏的env環境變量配置是定義在另外一個單獨的env文件裏面的,建議每個環境建立一個單獨的env文件供docker-compose.yml文件使用,比如下面是一個AT(自動化測試)環境的env文件內容示例:

# define xdp containers env
ASPNETCORE_ENVIRONMENT=at
ALIYUN_ACCESS_KEY=sxxdfdskjfkdsjkds
ALIYUN_ACCESS_SECRET=xdfsfjiwerowuoi
JWT_TOKEN=sdfsjkfjsdkfjlerwewe
IDENTITY_DB_CONNSTR=Server=192.168.16.150;Port=3306;Database=identity_at;Uid=xdpat;Pwd=xdpdba;Charset=utf8mb4
APIGATEWAY_DB_CONNSTR=Server=192.168.16.150;Port=3306;Database=services_at;Uid=xdpat;Pwd=xdpdba;Charset=utf8mb4
......
API_VERSION=AT-v1.0.0

這裏,最主要的環境變量就是ASPNETCORE_ENVIRONMENT,你需要指定這些要編排的微服務容器使用哪個環境的appSettings。同樣,這裏也引申出另一個問題,那就是配置的集中管理,可能你會說出類似Apollo,Spring Cloud Config,K8s Configmap之類的解決方案。這裏不是本文的重點,也就跳過。

快速實操體驗

現在我們來通過在Jenkins中觸發構建任務,可以看到如下圖所示的流水線任務狀態示意:

這樣,一個簡單的快速發布流水線就完成了,在單機多容器編排部署方面,Docker Compose是個不錯的選擇。

五、一些擴展

Consul服務發現容器編排

相比很多童鞋也都在使用Consul作為服務發現組件,我們也可以將Consul納入到Compose中來統一編排。例如,我們可以這樣來將其配置到docker-compose.yml中:

services:
  consul_agent_server:
    image: reg.xdp.xi-life.cn/xdp-consul-runtime:${IMAGE_TAG}
    container_name: xdp_consul_agent_server
    restart: always
    privileged: true
    mem_limit: 1024m
    memswap_limit: 1024m
    env_file:
      - ../docker-variables.env
    ports:
      - 8500:8500
    command: 
      agent -server -bootstrap-expect=1 -ui -node=xdp_local_server -client='0.0.0.0' -data-dir /consul/data -config-dir /consul/config -datacenter=xdp_local_dc
    volumes:
      - /etc/localtime:/etc/localtime
      - /docker/consul/data:/consul/data
      - /docker/consul/conf:/consul/config

這裏只使用到了一個Consul Server Agent,你可以配置一個3個Server節點的Consul Server集群,請自行查閱相關資料。此外,基於Compose我們也可以為API網關設置links從而實現服務發現的效果,當然前提是你的服務數量不多的前提下。這種方式是通過網絡層面幫你做了一層解析,從而實現多個容器之間的互連。這裏也推薦一下俺們成都地區的小馬甲老哥的一篇《docker-compose真香》的文章,他講解了docker的網橋模式。

基於Compose的編譯發布一體化

我們可以看到在很多開源項目中都是將編譯發布一體化的,因此我們可以看到在這些項目的Dockerfile中是這樣寫的:

FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
WORKDIR /app

COPY ./*.sln ./NuGet.Config ./
COPY ./build/*.props ./build/

# Copy the main source project files
COPY src/*/*.csproj ./
RUN for file in $(ls *.csproj); do mkdir -p src/${file%.*}/ && mv $file src/${file%.*}/; done

RUN dotnet restore

# Copy everything else and build app
COPY . .
RUN dotnet build -c Release

# api-publish

FROM build AS api-publish
WORKDIR /app/src/Exceptionless.Web

RUN dotnet publish -c Release -o out

# api

FROM mcr.microsoft.com/dotnet/core/aspnet:3.1 AS api
WORKDIR /app
COPY --from=api-publish /app/src/Exceptionless.Web/out ./
ENTRYPOINT [ "dotnet", "Exceptionless.Web.dll" ]

......

在Dockerfile中我們看到的是拉取.NET Core SDK來進行Restore、Build和Publish,進一步地提高了標準化的遷移性,也盡可能發揮Docker的集裝箱作用。
這時你可以在docker-compose.yml中定義Dockerfile告訴compose先幫我進行Build鏡像(這裏的build配置下就需要指定Dockerfile的位置):

services:
  api:
    build:
      context: .
    image: exceptionless/api:latest
    restart: always
    ......

六、小結

Docker是容器技術的核心、基礎,Docker Compose是一個基於Docker的單主機容器編排工具,功能並不像Docker Swarm和Kubernetes是基於Docker的跨主機的容器管理平台那麼豐富。

我想你看到這裏也應該有了自己的答案,結合我在最開頭給的建議,如果你處在一個小團隊中,綜合人員水平、技能儲備、運維成本 及 真實業務量要求,可以在開發測試環境(一般都是單主機環境的話)中使用Docker Compose進行初步編排。而在生產環境,即使是小團隊也建議上雲主機,利用雲的彈性為未來的業務發展做基礎,然後可以考慮使用雲上的K8s服務來進行生產級的容器編排。

 

 

作者:周旭龍

出處:https://edisonchou.cnblogs.com

本文版權歸作者和博客園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文鏈接。

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

新北清潔公司,居家、辦公、裝潢細清專業服務

※別再煩惱如何寫文案,掌握八大原則!

※教你寫出一流的銷售文案?

※超省錢租車方案

當我們創建HashMap時,底層到底做了什麼?

jdk1.7中的底層實現過程(底層基於數組+鏈表)

在我們new HashMap()時,底層創建了默認長度為16的一維數組Entry[ ] table。當我們調用map.put(key1,value1)方法向HashMap里添加數據的時候:

首先,調用key1所在類的hashCode()計算key1的哈希值,通過key1的hash值與數組的最大索引進行位運算以後,得到了在 Entry數組中的存放位置:

如果此位置上的數據為空,此時的key1-value1添加成功。

如果此位置上的數據不為空(意味着此位置已經存在一個或多個數據),比較key1和已經存在的一個或多個數據的哈希值:

如果key1的哈希值與已經存在的數據的哈希值都不相同,此時key1-value1添加成功。

如果key1的哈希值與已經存在的數據的某一個數據的哈希值相同,繼續比較:調用key1所在類的equals()方法:

如果equals()返回false,此時key1-value1添加成功;

如果equals()返回true,使用value1替換value2。

需要注意的是,若原來位置已有數據,則此時key1-value1和原來的數據以鏈表的方式存儲。

在不斷的添加過程中,會涉及到擴容問題,當數組容量大於數組現有長度乘以加載因子(如16*0.75,默認的加載因子為0.75)的時候,就會進行數組擴容,以減少哈希衝突(哈希衝突是指哈希函數算出來的地址被別的元素佔用了),提高查詢效率。默認的擴容方式,擴容為原來容量的2倍,並將原有的數據複製過來。

jdk1.8的底層實現過程(底層基於數組+鏈表+紅黑樹)

jdk1.8與jdk1.7中底層的創建過程相似,但有不同,首先,new HashMap()底層沒有創建出一個長度為16的數組,在調用put()方法時,判斷數組是否存在,如果不存在創建長度為16的Node[ ]數組。接下來的過程與jdk1.7相似。最後,當某一個索引位置上的元素以鏈表形式存在的數據個數>8且當前數組的長度>64時,此時此索引位置上的所有數據改為使用紅黑樹存儲。

在jdk1.7中,即使在“數組容量大於數組現有長度乘以加載因子”時擴容,也不可避免地會有哈希衝突存在,因此,在jdk1.8中引入紅黑樹是為了進一步減少哈希衝突,提高查詢效率。

紅黑樹是一種自平衡的二叉查找樹,是一種數據結構,典型的用途是實現關聯數組。根節點必須是黑色,其他每個節點要麼是紅色,要麼是黑色。

結論:HashMap鍵是不能重複的,去除重複的條件是依賴鍵的hashCode方法和equals方法,如果鍵是自己的對象類型,必須要重寫hashCode方法和equals方法,否則,不能去除重複的鍵。

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

新北清潔公司,居家、辦公、裝潢細清專業服務

※別再煩惱如何寫文案,掌握八大原則!

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

※超省錢租車方案

蘇聯秘密鈾礦計畫遺毒 中亞國家與輻射災難賽跑

摘錄自2019年11月6日中央社報導

吉爾吉斯邁盧蘇鎮在蘇聯時期有神祕稱號:Mailbox 200,是一項鈾礦秘密開發計畫執行地。如今城鎮蕭條,鄰近地質不穩處還有數座鈾尾礦堆隨時可能傾塌,污染環境。

這些鈾尾礦堆(鈾殘渣堆)重達數萬噸,只消一次天然災害,水流冲刷或山體滑坡,就有可能污染人口達1400萬且橫跨吉爾吉斯、烏茲別克和塔吉克接壤地帶的費爾干納盆地(Ferghana valley)飲用水。

根據歐盟執委會歐洲復興開發銀行(EBRD),這些接連遭蘇聯和吉爾吉斯政府忽略數十年的鈾尾礦堆必須盡快強化,以避免災難。為此,它們正為此籌募約3000萬歐元經費。此外,為在蘇聯時期也有鈾礦場運作的鄰國塔吉克進行類似清除工作,必需另外籌募4000萬歐元經費。

本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

※想知道購買電動車哪裡補助最多?台中電動車補助資訊懶人包彙整

南投搬家公司費用,距離,噸數怎麼算?達人教你簡易估價知識!

※教你寫出一流的銷售文案?

※超省錢租車方案

以電動巴士為先導 比亞迪今年將進軍韓國市場

比亞迪亞太汽車銷售事業部總經理劉學亮近日在韓國國際電動汽車展期間表示,比亞迪計畫以電動巴士為先導打入韓國市場,力爭在2015年年底之前讓韓國民眾享用上比亞迪的電動車。未來也可能會根據韓國消費者的具體需求,尋求E6系列計程車的進入韓國市場的可能性。   他表示,新能源汽車作為新興產業進入別國需經過包括政府認證在內的漫長流程,。比亞迪正在和不同的當地合作方接洽,希望最終可以在韓國找到適合發展電動車的新合作模式。

本站聲明:網站內容來源於EnergyTrend https://www.energytrend.com.tw/ev/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

※想知道購買電動車哪裡補助最多?台中電動車補助資訊懶人包彙整

南投搬家公司費用,距離,噸數怎麼算?達人教你簡易估價知識!

※教你寫出一流的銷售文案?

※超省錢租車方案

美國率先宣布開放中國產熟禽肉進口

摘錄自2019年11月09日中央通訊社美國報導

中美貿易談判雙方主談人10月25日通話時提到,將互相解禁禽肉進口。隨著美國8日率先宣布對中國解禁,分析認為,若中國也解禁,不僅美國農民將受益,也能緩解被非洲豬瘟抬高的肉價。

10月25日中美通話中協議,美方確認在進口中國熟製禽肉及鯰魚產品時,使用等效的監管體系;中國則就解除美國禽肉對中出口禁令,及應用肉類產品公共衛生訊息系統達成共識。美國政府於當地時間11月8日公布中國自產原料禽肉輸美的最終規則,宣布確認雙方相關監管體系等效,中國國產熟禽肉將可對美出口。

針對美方率先宣布的消息,中國海關總署今天以發言人名義表示,這讓中國繼加拿大、墨西哥、智利等國之後,有資格對美國出口自產原料熟製禽肉。

中國自2004年開始,就一直積極爭取這項目標,但美國先前僅允許中國使用美國或特定國家的禽肉原料加工後輸往美國。這次禽肉進口談判,正值非洲豬瘟疫情嚴重削減中國這一全球頭號豬肉消費國的生豬存欄數量。中國為填補蛋白質來源的市場空缺,正迅速增加肉類進口量,而雞肉正是替代豬肉最便宜的一種肉類。

此外,如果中國同樣解除對美國禽肉產品進口的限制,也將是美國農民和肉類加工企業的一大勝利。

本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

※想知道購買電動車哪裡補助最多?台中電動車補助資訊懶人包彙整

南投搬家公司費用,距離,噸數怎麼算?達人教你簡易估價知識!

※教你寫出一流的銷售文案?

※超省錢租車方案

不尾號限行!純電動車在北京受禮遇

4月2日,北京市政府發佈消息稱,自2015年4月11日至2016年4月10日,北京將繼續實施工作日機動車尾號限行措施,限行時間為7時至20時,範圍為五環路以內道路(不含五環路)。   而今年的限行措施特別提到,純電動小客車將不受尾號限行影響,具體執行時間由交管局公佈。其中,純電動小客車不受工作日高峰時段區域限行交通管理措施限制,具體執行時間由市公安交通管理部門另行公佈。   今年3月,國家交通運輸部發佈的《關於加快推進新能源汽車在交通運輸行業推廣應用的實施意見》便對推行電動車在內的新能源車有政策傾斜,提出要對新能源汽車不限行、不限購,對新能源計程車的運營權指標適當放寬。

本站聲明:網站內容來源於EnergyTrend https://www.energytrend.com.tw/ev/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

※想知道購買電動車哪裡補助最多?台中電動車補助資訊懶人包彙整

南投搬家公司費用,距離,噸數怎麼算?達人教你簡易估價知識!

※教你寫出一流的銷售文案?

※超省錢租車方案

GitLab Runner部署(kubernetes環境)

歡迎訪問我的GitHub

https://github.com/zq2599/blog_demos
內容:所有原創文章分類匯總及配套源碼,涉及Java、Docker、Kubernetes、DevOPS等;

關於GitLab CI

如下圖所示,開發者將代碼提交到GitLab后,可以觸發CI腳本在GitLab Runner上執行,通過編寫CI腳本我們可以完成很多使用的功能:編譯、構建、生成docker鏡像、推送到私有倉庫等:

本次實戰內容

今天咱們會一起完成以下操作:

  1. 部署minio,pipeline腳本中的cache功能由minio來實現;
  2. 配置和部署GitLab Runner;
  3. 編寫和運行pipeline腳本;

環境和版本信息

本次實戰涉及到多個服務,下面給出它們的版本信息供您參考:

  1. GitLab:Community Edition 13.0.6
  2. GilLab Runner:13.1.0
  3. kubernetes:1.15.3
  4. Harbor:1.1.3
  5. Minio:2020-06-18T02:23:35Z
  6. Helm:2.16.1

需要提前準備好的服務

以下服務需要您在實戰前提前準備好:

  1. 部署好GitLab,參考《群暉DS218+部署GitLab》
  2. 部署好Harbor,參考《群暉DS218+部署Harbor(1.10.3)》
  3. 部署好Helm,參考《部署和體驗Helm(2.16.1版本)》

準備完畢后開始實戰;

部署minio

minio作為一個獨立的服務部署,我將用docker部署在服務器:192.168.50.43

  1. 在宿主機準備兩個目錄,分別存儲minio的配置和文件,執行以下命令:
mkdir -p /var/services/homes/zq2599/minio/gitlab_runner \
&& chmod -R 777 /var/services/homes/zq2599/minio/gitlab_runner \
&& mkdir -p /var/services/homes/zq2599/minio/config \
&& chmod -R 777 /var/services/homes/zq2599/minio/config
  1. 執行docker命令創建minio服務,指定服務端口是9000,並且指定了access key(最短三位)和secret key(最短八位):
sudo docker run -p 9000:9000 --name minio \
-d --restart=always \
-e "MINIO_ACCESS_KEY=access" \
-e "MINIO_SECRET_KEY=secret123456" \
-v /var/services/homes/zq2599/minio/gitlab_runner:/gitlab_runner \
-v /var/services/homes/zq2599/minio/config:/root/.minio \
minio/minio server /gitlab_runner
  1. 瀏覽器訪問,輸入access key和secret key后登錄成功:
  2. 如下圖,點擊紅框中的圖標,創建一個bucket,名為runner
  3. 至此,minio已備好,接下來在kubernetes環境部署GitLab Runner;

GitLab Runner的類型

從使用者的維度來看,GitLab Runner的類型分為sharedspecific兩種:

  1. 如果您想創建的GitLab Runner給所有GitLab倉庫使用,就要創建shared類型;
  2. 如果您的GitLab Runner只用於給某個固定的Gitlab倉庫,就要創建specific類型;

今天的實戰,我們創建的是specific類型,即先有GitLab代碼倉庫,然後創建該倉庫專用的runner,所以請您提前準備好GitLab倉庫;

準備GitLab配置信息(specific)

在部署GitLab Runner之前,要準備兩個關鍵的配置信息,以便GitLab Runner啟動后可以順利連接上GitLab:

  1. 瀏覽器訪問GitLab,打開用來做CI的代碼倉庫,點擊Settings -> CI/CD -> Runners -> Expand:
  2. 如下圖,紅框1中是gitlab url,紅框2中是registration token,記好這兩個參數,稍後會用到:

準備GitLab配置信息(shared)

本次實戰不會創建shared類型的runner,如果您要創建該類型runner,只需按照以下方法準備信息即可,創建出來的runner就是所有倉庫都能使用的了:

  1. 以管理員身份登錄GitLab;
  2. 按照下圖紅框的順序取得gitlab urlregistration token

部署RitLab Runner

  1. 請確保當前可以通過kubectl命令在kubernetes進行常規操作;
  2. 創建名為gitlab-runner的namespace:
kubectl create namespace gitlab-runner
  1. 創建一個secret,把minio的access key和secret key存進去,在後面配置cache的時候會用到:
kubectl create secret generic s3access \
--from-literal=accesskey="access" \
--from-literal=secretkey="secret123456" -n gitlab-runner
  1. 用helm部署GitLab Runner之前,先把chart的倉庫添加到helm的倉庫列表中:
helm repo add gitlab https://charts.gitlab.io
  1. 下載GitLab Runner的chart:
helm fetch gitlab/gitlab-runner
  1. 當前目錄會多出一個文件gitlab-runner-0.18.0.tgz,解壓:
tar -zxvf gitlab-runner-0.18.0.tgz
  1. 解壓后是名為gitlab-runner的文件夾,內容如下圖所示,接下來要修改裏面的三個文件:
  2. 打開values.yaml,裏面有四處需要修改:
  3. 第一處,找到已被註釋掉的gitlabUrl參數位置,添加gitlabUrl的配置,其值就是前面在GitLab網頁取得的gitlab url參數,如下圖紅框:
  4. 第二處,找到已被註釋掉的runnerRegistrationToken參數位置,添加runnerRegistrationToken的配置,其值就是前面在GitLab網頁取得的registration token參數,如下圖紅框:
  5. 找到rbac的配置,將createclusterWideAccess的值都改成true(創建RBAC、創建容器gitlab-bastion用於管理job的容器):
  6. 設置此GitLab Runner的tagk8s,在pipeline腳本中可以通過指定tag為k8s,這樣pipeline就會在這個Gitlab Runner上允許:
  7. 找到cache的配置,在修改之前,cache的配置如下圖,可見值為空內容的大括號,其餘信息全部被註釋了:
  8. 修改后的cache配置如下圖,紅框1中原先的大括號已去掉,紅框2中的是去掉了註釋符號,內容不變,紅框3中填寫的是minio的訪問地址,紅框4中的是去掉了註釋符號,內容不變:
  9. 上圖紅框4中的s3CacheInsecure參數等於false表示對minio的請求為http(如果是true就是https),但實際證明,當前版本的chart中該配置是無效的,等到運行時還是會以https協議訪問,解決此問題的方法是修改templates目錄下的_cache.tpl文件,打開此文件,找到下圖紅框中的內容:
  10. 將上圖紅框中的內容替換成下面紅框中的樣子,即刪除原先的if判斷和對應的end這兩行,直接給CACHE_S3_INSECURE賦值:
  11. 接下來要修改的是templates/configmap.yaml文件,在這裏面將宿主機的docker的sock映射給runner executor,這樣job中的docker命令就會發到宿主機的docker daemon上,由宿主機來執行,打開templates/configmap.yaml,找到下圖位置,我們要在紅框1和紅框2之間添加一段內容:
  12. 要在上圖紅框1和紅框2之間添加的內容如下:
cat >>/home/gitlab-runner/.gitlab-runner/config.toml <<EOF
            [[runners.kubernetes.volumes.host_path]]
              name = "docker"
              mount_path = "/var/run/docker.sock"
              read_only = true
              host_path = "/var/run/docker.sock"
    EOF
  1. 添加上述內容后,整體效果如下,紅框中就是新增內容:
  2. 修改完畢,回到values.yam所在目錄,執行以下命令即可創建GitLab Runner:
helm install \
--name-template gitlab-runner \
-f values.yaml . \
--namespace gitlab-runner
  1. 檢查pod是否正常:
  2. 看pod日誌也並未發現異常:
  3. 回到GitLab的runner頁面,可見新增一個runner:

    至此,整個GitLab CI環境已部署完畢,接下來簡單的驗證環境是否OK;

驗證

  1. 在GitLab倉庫中,增加名為.gitlab-ci.yml的文件,內容如下:
# 設置執行鏡像
image: busybox:latest

# 整個pipeline有兩個stage
stages:
- build
- test

# 定義全局緩存,緩存的key來自分支信息,緩存位置是vendor文件夾
cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
  - vendor/

before_script:
  - echo "Before script section"

after_script:
  - echo "After script section"

build1:
  stage: build
    tags:
  - k8s
  script:
    - echo "將內容寫入緩存"
    - echo "build" > vendor/hello.txt

test1:
  stage: test
  script:
    - echo "從緩存讀取內容"
    - cat vendor/hello.txt
  1. 提交上述腳本到GitLab,如下圖,可見pipeline會被觸發,狀態為pending是因為正在等待runner創建executor pod:
  2. 稍後就會執行成功,點開看結果:
  3. 點開build1的圖標,可見此job的輸出信息:
  4. 點開test1的圖標,可見對應的控制台輸出,上一個job寫入的數據被成功讀取:

    至此,GitLab Runner已經成功在kubernetes環境部署和運行,接下來的文章,我們會一起實戰將SpringBoot應用構建成docker鏡像並推送到Harbor;

歡迎關注我的公眾號:程序員欣宸

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

※想知道購買電動車哪裡補助最多?台中電動車補助資訊懶人包彙整

南投搬家公司費用,距離,噸數怎麼算?達人教你簡易估價知識!

※教你寫出一流的銷售文案?

※超省錢租車方案

學界首次發現空污與腦癌有關 慎防奈米級「超細懸浮微粒」

環境資訊中心綜合外電;姜唯 編譯;林大利 審校

本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

※想知道購買電動車哪裡補助最多?台中電動車補助資訊懶人包彙整

南投搬家公司費用,距離,噸數怎麼算?達人教你簡易估價知識!

※教你寫出一流的銷售文案?

※超省錢租車方案

Dubbo想要個網關怎麼辦?試試整合Spring Cloud Gateway

一、背景

在微服務架構中 API網關 非常重要,網關作為全局流量入口並不單單是一個反向路由,更多的是把各個邊緣服務(Web層)的各種共性需求抽取出來放在一個公共的“服務”(網關)中實現,例如安全認證、權限控制、限流熔斷、監控、跨域處理、聚合API文檔等公共功能。

 

在以 Dubbo 框架體系來構建的微服務架構下想要增加API網關,如果不想自研開發的情況下在目前的開源社區中幾乎沒有找到支持dubbo協議的主流網關,但是 Spring Cloud 體系下卻有兩個非常熱門的開源API網關可以選擇;本文主要介紹如何通過 Nacos 整合 Spring Cloud GatewayDubbo 服務

 

二、傳統 dubbo 架構

dubbo屬於rpc調用,所以必須提供一個web層的服務作為http入口給客戶端調用,並在上面提供安全認證等基礎功能,而web層前面對接Nginx等反向代理用於統一入口和負載均衡。

web層一般是根據業務模塊來切分的,用於聚合某個業務模塊所依賴的各個service服務

PS:我們能否把上圖中的web層全部整合在一起成為一個API網關呢?(不建議這樣做)

因為這樣的web層並沒有實現 泛化調用 必須引入所有dubbo服務的api依賴,會使得網關變得非常不穩定,任何服務的接口變更都需要修改網關中的api依賴!

 

三、整合 Srping Cloud Gateway 網關

下面就開始聊聊直接拿熱門的 Srping Cloud Gateway 來作為dubbo架構體系的網關是否可行,首先該API網關是屬於 Spring Cloud 體系下的組件之一,要整合dubbo的話需要解決以下問題:

  1. 打通註冊中心:spring cloud gateway 需要通過註冊中心發現下游服務,而 dubbo 也需要通過註冊中心實現服務的註冊與發現,如果兩者的註冊中心不能打通的話就會變成雙註冊中心架構就非常複雜了!
  2. 協議轉換: gateway 使用http傳輸協議調用下游服務,而dubbo服務默認使用的是tcp傳輸協議

上面提到的第一個問題“打通註冊中心”其實已經不是問題了,目前dubbo支持 ZookeeperNacos 兩個註冊中心,而 Spring Cloud 自從把 @EnableEurekaClient 改為 @EnableDiscoveryClient 之後已經基本上支持所有主流的註冊中心了,本文將使用 Nacos 作為註冊中心打通兩者

 

3.1. 方式一

把傳統dubbo架構中的 Nginx 替換為 Spring Cloud Gateway ,並把 安全認證 等共性功能前移至網關處實現

由於web層服務本身提供的就是http接口,所以網關層無需作協議轉換,但是由於 安全認證 前移至網關了需要通過網絡隔離的手段防止被繞過網關直接請求後面的web層

 

3.2. 方式二

dubbo服務本身修改或添加 rest 傳輸協議的支持,這樣網關就可以通過http傳輸協議與dubbo服務通信了

rest傳輸協議:基於標準的Java REST API——JAX-RS 2.0(Java API for RESTful Web Services的簡寫)實現的REST調用支持

目前版本的dubbo已經支持dubbo、rest、rmi、hessian、http、webservice、thrift、redis等10種傳輸協議了,並且還支持同一個服務同時定義多種協議,例如配置 protocol = { “dubbo”, “rest” } 則該服務同時支持 dubborest 兩種傳輸協議

 

3.3. 總結

方式一 對比 方式二 多了一層web服務所以多了一次網絡調用開銷,但是優點是各自的職責明確單一,web層可以作為聚合層用於聚合多個service服務的結果經過融合加工一併返回給前端,所以這種架構下能大大減少服務的 循環依賴

 

四、代碼實踐

依賴環境

  • lombok
  • jdk 1.8
  • Nacos 1.3
  • Spring Boot 2.2.8.RELEASE
  • Spring Cloud Hoxton.SR5
  • Spring Cloud Alibaba 2.2.1.RELEASE

 

在根目錄的 pom.xml 中定義全局的依賴版本

    <properties>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <java.version>8</java.version>

        <spring-boot-dependencies.version>2.2.8.RELEASE</spring-boot-dependencies.version>
        <spring-cloud-dependencies.version>Hoxton.SR5</spring-cloud-dependencies.version>
        <spring-cloud-alibaba-dependencies.version>2.2.1.RELEASE</spring-cloud-alibaba-dependencies.version>
        <jaxrs.version>3.12.1.Final</jaxrs.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot-dependencies.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>

            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud-dependencies.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>

            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-alibaba-dependencies</artifactId>
                <version>${spring-cloud-alibaba-dependencies.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

 

4.1. 創建dubbo-api工程

分別定義兩個api接口

DubboService 使用dubbo協議的服務

public interface DubboService {
    String test(String param);
}

RestService 使用rest協議的服務

public interface RestService {
    String test(String param);
}

 

4.2. 創建web-dubbo工程

使用 方式一 整合對接網關,這裏為了簡化在同一個服務下只使用邏輯分層定義controller層與service層,並沒有做服務拆分

4.2.1. 創建配置

定義 spring boot 配置

server:
  port: 8081

spring:
  application:
    name: zlt-web-dubbo
  main:
    allow-bean-definition-overriding: true
  cloud:
    nacos:
      server-addr: 192.168.28.130:8848
      username: nacos
      password: nacos

server.port:配置應用服務器暴露的端口

spring.cloud.nacos:配置 spring cloud 的註冊中心相關參數,nacos 的配置需要改為自己環境所對應

定義 dubbo 配置

dubbo:
  scan:
    base-packages: org.zlt.service
  protocols:
    dubbo:
      name: dubbo
      port: -1
  registry:
    address: spring-cloud://localhost
  consumer:
    timeout: 5000
    check: false
    retries: 0
  cloud:
    subscribed-services:

dubbo.scan.base-packages:指定 Dubbo 服務實現類的掃描基準包

dubbo.protocols:服務暴露的協議配置,其中子屬性 name 為協議名稱,port 為協議端口( -1 表示自增端口,從 20880 開始)

dubbo.registry.address:Dubbo 服務註冊中心配置,其中子屬性 address 的值 “spring-cloud://localhost”,說明掛載到 Spring Cloud 註冊中心

 

4.2.2. 創建DubboService的實現類

通過 protocol = "dubbo" 指定使用 dubbo協議 定義服務

@Service(protocol = "dubbo")
public class DubboServiceImpl implements DubboService {
    @Override
    public String test(String param) {
        return "dubbo service: " + param;
    }
}

 

4.2.3. 創建Controller類

使用 Spring Boot@RestController 註解定義web服務

@RestController
public class WebController {
    @Autowired
    private DubboService dubboService;

    @GetMapping("/test/{p}")
    public String test(@PathVariable("p") String param) {
        return dubboService.test(param);
    }
}

 

4.3. 創建rest-dubbo工程

使用 方式二 整合對接網關,由於該服務是通過dubbo來創建rest服務,所以並不需要使用 Spring Boot 內置應用服務

4.3.1. 創建配置

定義 spring boot 配置

spring:
  application:
    name: zlt-rest-dubbo
  main:
    allow-bean-definition-overriding: true
  cloud:
    nacos:
      server-addr: 192.168.28.130:8848
      username: nacos
      password: nacos

因為不使用 Spring Boot 內置的應用服務所以這裏並不需要指定 server.port

定義 dubbo 配置

dubbo:
  scan:
    base-packages: org.zlt.service
  protocols:
    dubbo:
      name: dubbo
      port: -1
    rest:
      name: rest
      port: 8080
      server: netty
  registry:
    address: spring-cloud://localhost
  consumer:
    timeout: 5000
    check: false
    retries: 0
  cloud:
    subscribed-services:

dubbo.protocols:配置兩種協議,其中rest協議定義 8080 端口並使用 netty 作為應用服務器

 

4.3.2. 創建RestService的實現類

通過 protocol = "rest" 指定使用 rest協議 定義服務

@Service(protocol = "rest")
@Path("/")
public class RestServiceImpl implements RestService {
    @Override
    @Path("test/{p}")
    @GET
    public String test(@PathParam("p") String param) {
        return "rest service: " + param;
    }
}

 

4.4. 創建Spring Cloud Gateway工程

定義 spring boot 配置

server:
  port: 9900

spring:
  application:
    name: sc-gateway
  main:
    allow-bean-definition-overriding: true
  cloud:
    nacos:
      server-addr: 192.168.28.130:8848
      username: nacos
      password: nacos

server.port:定義網關端口為 9090

定義網關配置

spring:
  cloud:
    gateway:
      discovery:
        locator:
          lowerCaseServiceId: true
          enabled: true
      routes:
        - id: web
          uri: lb://zlt-web-dubbo
          predicates:
            - Path=/api-web/**
          filters:
            - StripPrefix=1
        - id: rest
          uri: lb://zlt-rest-dubbo
          predicates:
            - Path=/api-rest/**
          filters:
            - StripPrefix=1

分別定義兩個路由策略:

  • 路徑 /api-web/ 為請求 web-dubbo 工程
  • 路徑 /api-rest/ 為請求 rest-dubbo 工程

 

4.5. 測試

分別啟動:Nacos、sc-gateway、web-dubbo、rest-dubbo 工程,通過網關的以下兩個接口分別測試兩種整合方式

  1. http://127.0.0.1:9900/api-web/test/abc :請求 web-dubbo 工程測試整合方式一
  2. http://127.0.0.1:9900/api-rest/test/abc :請求 rest-dubbo 工程測試整合方式二

 

五、demo下載

ide需要安裝 lombok 插件

https://github.com/zlt2000/dubboSpringCloud

 
掃碼關注有驚喜!

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

※想知道購買電動車哪裡補助最多?台中電動車補助資訊懶人包彙整

南投搬家公司費用,距離,噸數怎麼算?達人教你簡易估價知識!

※教你寫出一流的銷售文案?

※超省錢租車方案

特斯拉推儲能電池系統 德國石墨陽極商 SGL 受惠

美國豪華電動車製造商特斯拉 (Tesla) 跨界家用電池,德國汽車碳纖維生產商西格里集團 (SGL Carbon SE) 在投資人期待該公司可望因此受惠的激勵下,股價創下近三個月以來最大單日漲幅。   SGL 主要是為日立、Panasonic 這些日本電子大廠供應石墨陽極材料,而日立、Panasonic 則會將電池元件賣給特斯拉。   Bankhaus Lampe 分析師 Marc Gabriel 說,SGL 是少數幾家能因電池需求增溫而受惠的德國業者。根據報導,SGL 發言人已確認,該公司的確是日立、Panasonic 的石墨陽極材料供應商。

本站聲明:網站內容來源於EnergyTrend https://www.energytrend.com.tw/ev/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

※想知道購買電動車哪裡補助最多?台中電動車補助資訊懶人包彙整

南投搬家公司費用,距離,噸數怎麼算?達人教你簡易估價知識!

※教你寫出一流的銷售文案?

※超省錢租車方案