Quantcast
Channel: 小惡魔 – 電腦技術 – 工作筆記 – AppleBOY
Viewing all 325 articles
Browse latest View live

Go 語言使用 Select 四大用法

$
0
0

photo

本篇教學要帶大家認識 Go 語言Select 用法,相信大家對於 switch 並不陌生,但是 selectswitch 有個共同特性就是都過 case 的方式來處理,但是 select 跟 switch 處理的事情完全不同,也完全不相容。來看看 switch 有什麼特性: 各種類型及型別操作,接口 interface{} 型別判斷 variable.(type),重點是會依照 case 順序依序執行。底下看個例子:

package main

var (
    i interface{}
)

func convert(i interface{}) {
    switch t := i.(type) {
    case int:
        println("i is interger", t)
    case string:
        println("i is string", t)
    case float64:
        println("i is float64", t)
    default:
        println("type not found")
    }
}

func main() {
    i = 100
    convert(i)
    i = float64(45.55)
    convert(i)
    i = "foo"
    convert(i)
    convert(float32(10.0))
}

跑出來的結果如下:

i is interger 100
i is float64 +4.555000e+001
i is string foo
type not found

select 的特性就不同了,只能接 channel 否則會出錯,default 會直接執行,所以沒有 default 的 select 就會遇到 blocking,假設沒有送 value 進去 Channel 就會造成 panic,底下拿幾個實際例子來解說。

教學影片

此篇部落格內容有錄製成影片放在 Udemy 平台上面,有興趣的可以直接參考底下:

Random Select

同一個 channel 在 select 會隨機選取,底下看個例子:

package main

import "fmt"

func main() {
    ch := make(chan int, 1)

    ch <- 1
    select {
    case <-ch:
        fmt.Println("random 01")
    case <-ch:
        fmt.Println("random 02")
    }
}

執行後會發現有時候拿到 random 01 有時候拿到 random 02,這就是 select 的特性之一,case 是隨機選取,所以當 select 有兩個 channel 以上時,如果同時對全部 channel 送資料,則會隨機選取到不同的 Channel。而上面有提到另一個特性『假設沒有送 value 進去 Channel 就會造成 panic』,拿上面例子來改:

func main() {
    ch := make(chan int, 1)

    select {
    case <-ch:
        fmt.Println("random 01")
    case <-ch:
        fmt.Println("random 02")
    }
}

執行後會發現變成 deadlock,造成 main 主程式爆炸,這時候可以直接用 default 方式解決此問題:

func main() {
    ch := make(chan int, 1)

    select {
    case <-ch:
        fmt.Println("random 01")
    case <-ch:
        fmt.Println("random 02")
    default:
        fmt.Println("exit")
    }
}

主程式 main 就不會因為讀不到 channel value 造成整個程式 deadlock。

Timeout 超時機制

用 select 讀取 channle 時,一定會實作超過一定時間後就做其他事情,而不是一直 blocking 在 select 內。底下是簡單的例子:

package main

import (
    "fmt"
    "time"
)

func main() {
    timeout := make(chan bool, 1)
    go func() {
        time.Sleep(2 * time.Second)
        timeout <- true
    }()
    ch := make(chan int)
    select {
    case <-ch:
    case <-timeout:
        fmt.Println("timeout 01")
    }
}

建立 timeout channel,讓其他地方可以透過 trigger timeout channel 達到讓 select 執行結束,也或者有另一個寫法是透握 time.After 機制

    select {
    case <-ch:
    case <-timeout:
        fmt.Println("timeout 01")
    case <-time.After(time.Second * 1):
        fmt.Println("timeout 02")
    }

可以注意 time.After 是回傳 chan time.Time,所以執行 select 超過一秒時,就會輸出 timeout 02

檢查 channel 是否已滿

直接來看例子比較快:

package main

import (
    "fmt"
)

func main() {
    ch := make(chan int, 1)
    ch <- 1
    select {
    case ch <- 2:
        fmt.Println("channel value is", <-ch)
        fmt.Println("channel value is", <-ch)
    default:
        fmt.Println("channel blocking")
    }
}

先宣告 buffer size 為 1 的 channel,先丟值把 channel 填滿。這時候可以透過 select + default 方式來確保 channel 是否已滿,上面例子會輸出 channel blocking,我們再把程式改成底下

func main() {
    ch := make(chan int, 2)
    ch <- 1
    select {
    case ch <- 2:
        fmt.Println("channel value is", <-ch)
        fmt.Println("channel value is", <-ch)
    default:
        fmt.Println("channel blocking")
    }
}

把 buffer size 改為 2 後,就可以繼續在塞 value 進去 channel 了,這邊的 buffer channel 觀念可以看之前的文章『用五分鐘了解什麼是 unbuffered vs buffered channel

select for loop 用法

如果你有多個 channel 需要讀取,而讀取是不間斷的,就必須使用 for + select 機制來實現,更詳細的實作可以參考『15 分鐘學習 Go 語言如何處理多個 Channel 通道

package main

import (
    "fmt"
    "time"
)

func main() {
    i := 0
    ch := make(chan string, 0)
    defer func() {
        close(ch)
    }()

    go func() {
    LOOP:
        for {
            time.Sleep(1 * time.Second)
            fmt.Println(time.Now().Unix())
            i++

            select {
            case m := <-ch:
                println(m)
                break LOOP
            default:
            }
        }
    }()

    time.Sleep(time.Second * 4)
    ch <- "stop"
}

上面例子可以發現執行後如下:

1574474619
1574474620
1574474621
1574474622

其實把 default 拿掉也可以達到目的

select {
case m := <-ch:
    println(m)
    break LOOP

當沒有值送進來時,就會一直停在 select 區段,所以其實沒有 default 也是可以正常運作的,而要結束 for 或 select 都需要透過 break 來結束,但是要在 select 區間直接結束掉 for 迴圈,只能使用 break variable 來結束,這邊是大家需要注意的地方。


用 15 分鐘快速打造 Laravel 開發環境

$
0
0

cover page

相信大家對 Laravel 都很熟悉,但是初學者或是新進同事要快速入門 Laravel 最大的門檻就是該如何在短時間內在本機電腦快速安裝好公司專案。這時候使用 Laradock 就是一個最佳時機,透過 Docker 容器話,快速切換 PHP 版本,或者是安裝額外的服務像是 MySQL, MariaDB, phpMyAdmin 或 nginx 等服務,讓本機端不受到自訂安裝套件的困擾,用完隨時關閉,完全不會影響到電腦環境。底下我會介紹使用 Laradock 該注意的事情。完整詳細的操作步驟可以直接看 Youtube 影片。

教學影片

同步放在 Udemy 平台上面,有興趣的可以直接參考底下:

前置處理

先來定義 laradock 該如何跟既有或者是全新專案結合,底下提供一種目錄結構

├── laradock
└── www

其實蠻好懂的,先建立空目錄,www 代表專案的程式碼,而 laradock 就是本機端開發環境。你也可以直接將 laradock 放進 www 內也可以。

編輯 laradock/.env (從 env-example 複製)

修改

APP_CODE_PATH_HOST=../www

專案架構調整為:

├── laradock
└── www

如果機器本身已經有 nginx, apachetraefik,請將 nginx container port 修改為:

NGINX_HOST_HTTP_PORT=8000
NGINX_HOST_HTTPS_PORT=4430

下載專案原始碼

如果已經有 Source Code 了請忽略此步驟,如果是全新的專案,請先進入 workspace 容器:

docker-compose exec workspace bash

進去後預設會在 /var/www 目錄底下,接著下載 laravel 官方原始碼

composer create-project laravel/laravel --prefer-dist .

完成後請離開 container,就可以看到在 www 底下有完整的 laravel 代碼,避免跟主機 Host 衝突。接著啟動專案 (nginx + mariadb)

docker-compose up -d nginx mariadb

設定 nginx 檔案

先假設網域名稱為 laravel.test,先複製 config

cp -r nginx/sites/laravel.conf.example nginx/sites/laravel.test.conf

修改 nginx/sites/laravel.test.conf

# 將底下
root /var/www/laravel/public
# 改成
root /var/www/public

這邊我有發個 PR 到 Laradock,最後新增 laravel.test 到 /etc/hosts 檔案

修改目錄權限

由於 php-fpm 容器運行的 www-data 的使用者,所以您必須在 Host 設定相對應的 uid 及 gid,先進入 php-fpm 來取得 www-data 個人資訊:

$ docker-compose exec php-fpm id www-data
uid=1000(www-data) gid=1000(www-data) groups=1000(www-data)

設定權限

chown -R 1000:1000 www/storage/

編輯 laradock/docker-compose.yml

Docker 預設使用 172.21.x 開頭的 IP,可以修改 docker-compose.yml 來調整網路設定:

networks:
  frontend:
    driver: ${NETWORKS_DRIVER}
    ipam:
      driver: default
      config:
        - subnet: 192.168.100.0/24
  backend:
    driver: ${NETWORKS_DRIVER}
    ipam:
      driver: default
      config:
        - subnet: 192.168.101.0/24
  default:
    driver: ${NETWORKS_DRIVER}
    ipam:
      driver: default
      config:
        - subnet: 192.168.110.0/24

避免跟公司網路 172.xxx.xxx.xxx 網域衝到造成網路斷線。

用 GitHub Actions 部署 Go 語言服務

$
0
0

GitHub Actions 也推出一陣子了,相信有不少雷,也是有很多優勢,未來在 GitHub 上面串接任何開源專案,都可以免費使用,過幾年可以看看 GitHub Actions 對 Travis 的影響是多少?本篇要來介紹如何透過 GitHub Actions 來部署 Go 語言服務,會用一個簡單 httpd 範例教大家如何透過 Docker 方式來更新。使用 Go 語言基本服務流程大致上會是『測試 -> 編譯 -> 上傳 -> 啟動』,透過這四個步驟來學習 GitHub Actions 該如何設定。

  • 測試: Unit Testing 多一層保護
  • 編譯: 透過 go build 編譯出 Binary 檔案
  • 上傳: 寫 Dockerfile 將 Binary 包進容器內
  • 啟動: 透過 docker-compose 方式來更新服務

影片介紹

同步放在 Udemy 平台上面,有興趣的可以直接參考底下:

啟動 GitHub Actions

只要在專案內建立 .github/workflows/ 目錄,裡面可以放置多個 YAML 檔案,上傳至 GitHub,就可以開始使用了。我們先在該目錄建立一個 deploy.yml 檔案

name: Build and Test
on:
  push:
    branches:
      - master
  pull_request:
jobs:
  lint:
    strategy:
      matrix:
        platform: [ubuntu-latest, windows-latest, macos-latest]
    runs-on: ${{ matrix.platform }}
    steps:
    - name: hello world
      run: |
        echo "Hello World"

可以看到現在可以直接在 GitHub 上面執行 Ubuntu 或 Windows 或 MacOS,詳細版本資訊可以參考這邊。接著針對 GO 語言四個步驟來分別撰寫 YAML 設定。

Go 語言流程

第一個步驟是下載專案原始碼,這邊跟其他 CI/CD 工具不一樣的是預設流程不會 checkout source code,必須要自己指定。

steps:
- name: Check out code
  uses: actions/checkout@v1

第二個步驟是安裝 Go 語言環境,原因 Ubuntu 環境預設是空的,所以任何語言都需要再額外安裝:

strategy:
  matrix:
    go-version: [1.13.x]
steps:
- name: Install Go
  uses: actions/setup-go@v1
  with:
    go-version: ${{ matrix.go-version }}

第三步驟是測試

- name: Tesing
  run: |
    go test -v .

第四步驟是編譯 binary 檔案

- name: Build binary
  run: |
    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -v -a -o release/linux/amd64/helloworld

第五個步驟是將 binary 檔案包成容器上傳到 Docker Hub:

- name: Publish to Registry
  uses: elgohr/Publish-Docker-Github-Action@2.9
  with:
    name: appleboy/helloworld
    username: appleboy
    password: ${{ secrets.docker_password }}
    dockerfile: docker/helloworld/Dockerfile.linux.amd64

其中 Dockerfile 內容為:

FROM plugins/base:linux-amd64

LABEL maintainer="Bo-Yi Wu <appleboy.tw@gmail.com>" \
  org.label-schema.name="helloworld" \
  org.label-schema.vendor="Bo-Yi Wu" \
  org.label-schema.schema-version="1.0"

EXPOSE 8080

COPY release/linux/amd64/helloworld /bin/

ENTRYPOINT ["/bin/helloworld"]

最後步驟為連線到遠端伺服器並重新啟動服務,最簡單方式就是透過 docker-compose 來重新啟動。這邊透過 ssh-actions

- name: Update the API service
  uses: appleboy/ssh-action@v0.0.6
  with:
    host: ${{ secrets.ssh_host }}
    username: deploy
    key: ${{ secrets.ssh_key }}
    script_stop: true
    script: |
      cd golang && docker-compose pull && docker-compose up -d

全部設定檔如下:

name: Build and Test
on:
  push:
    branches:
      - master
  pull_request:
jobs:
  build:
    strategy:
      matrix:
        platform: [ubuntu-latest]
    runs-on: ${{ matrix.platform }}
    steps:
    - name: Install Go
      uses: actions/setup-go@v1
      with:
        go-version: ${{ matrix.go-version }}

    - name: Check out code
      uses: actions/checkout@v1

    - name: Tesing
      run: |
        make test

    - name: Build binary
      run: |
        make build_linux_amd64

    - name: Publish to Registry
      uses: elgohr/Publish-Docker-Github-Action@2.9
      with:
        name: appleboy/helloworld
        username: appleboy
        password: ${{ secrets.docker_password }}
        dockerfile: docker/helloworld/Dockerfile.linux.amd64

    - name: Update the API service
      uses: appleboy/ssh-action@v0.0.6
      with:
        host: ${{ secrets.ssh_host }}
        username: deploy
        key: ${{ secrets.ssh_key }}
        script_stop: true
        script: |
          cd golang && docker-compose pull && docker-compose up -d

使用容器當做基底

從上面的設定檔我會有個疑問,就是每一個 Job 都要從最初始化安裝環境,像上面就是安裝 Go 語言環境。那能不能直接選用 Go 官方提供的容器當作基底,這樣就可以少裝一個步驟,答案是可以的,每一個 Job 都可以指定不同的容器來啟動

jobs:
  build:
    strategy:
      matrix:
        platform: [ubuntu-latest]
    runs-on: ${{ matrix.platform }}
    container: golang:1.13

上面這段意思是就是,先拿 Ubuntu 當作系統,在上面跑 golang:1.13,這樣 build 這個 Job 就是以官方的容器當作基底做後續處理。也就是不再依賴 actions/setup-go@v1 套件了。

心得

雖然 GitHub Actions 已經正式 Release 了,但是要用在 Production 可能還需要等一陣子,原因是貿然轉換過來,需要一些時間來確認是否全部的流程都有人寫成 Plugin 放在 Marketplace,找不到的話,就必須要自己去撰寫,有好處也有壞處。

使用 Go Channel 及 Goroutine 時機

$
0
0

golang logo

相信不少開發者肯定聽過 Go 語言之所以讓人非常喜歡,就是因為 Go concurrency,如果您對於 concurrency 不了解的朋友們,可以直接參考官網的範例開始了解,範例會帶您一步一步了解什麼是 Channel 什麼是 Go concurrency?本篇會介紹 Channel 使用時機,在大部分寫 application 時,老實說很少用到 Channel,所以很多人其實不知道該在哪種場景需要使用 Channel,底下這句名言大家肯定聽過:

Do not communicate by sharing memory; instead, share memory by communicating.

本篇會用簡單的例子來帶大家理解上述名言。

教學影片

更多實戰影片可以參考我的 Udemy 教學系列

Channel 基本知識

Channel 分成讀跟寫,如果在實戰內用到非常多 Channel,請注意在程式碼任何地方對 Channel 進行讀寫都有可能造成不同的狀況,所以為了避免團隊內濫用 Channel,通常我都會限定在哪個情境只能寫,在哪個情境只能讀。如果混著用,最後會非常難除錯,也造成 Reviewer 非常難閱讀跟理解。

Write(chan<- int)
Read(<-chan int)
ReadWrite(chan int)

分辨讀寫非常容易,請看 <- 符號放在哪邊,chan<- 指向自己就是寫,<-chan 離開自己就是讀,相當好分辨,如果 func 內讀寫都需要使用,則不需要使用任何箭頭符號,但是我會建議把讀寫的邏輯都拆開不同的 func 處理,對於閱讀上非常有幫助。

Communicating by sharing memory

其實不管在哪一個語言都會有類似範例,底下用 Go 來舉例

package main

import (
    "fmt"
    "sync"
)

func addByShareMemory(n int) []int {
    var ints []int
    var wg sync.WaitGroup

    wg.Add(n)
    for i := 0; i < n; i++ {
        go func(i int) {
            defer wg.Done()
            ints = append(ints, i)
        }(i)
    }

    wg.Wait()
    return ints
}

func main() {
    foo := addByShareMemory(10)
    fmt.Println(len(foo))
    fmt.Println(foo)
}

請直接將程式碼放到您的電腦執行,原本 ints 應該可以正常拿到 10 個值,但是你會發現每次拿到的結果都是不同的,原因就是在 func 內宣告 ints 是 []int,而在 goroutine 內是共享這變數,但是有可能在同一時間點對同位址 memory 進行讀寫,所以可以看到每次執行出來的結果都是不同的。用 goroutine 進行變數讀寫時,盡量不要用 share memory 來共享,有時候出錯真的蠻難 debug 的。

底下講幾個解決方式,有些方式不適合用在專案內。第一個就是透過修改 GOMAXPROCS:

package main

import (
    "fmt"
    "runtime"
    "sync"
)

func init() {
    runtime.GOMAXPROCS(1)
}

原本會根據 CPU 有多少顆,做多少平行處理,但是可以透過 GOMAXPROCS 設定使用的 CPU 數量,這樣執行出來的結果就可以符合預期,但是誰會在 application 內使用 GOMAXPROCS,完全不合邏輯啊。另一種方式就是使用 sync 來解決:

func addByShareMemory(n int) []int {
    var ints []int
    var wg sync.WaitGroup
    var mux sync.Mutex

    wg.Add(n)
    for i := 0; i < n; i++ {
        go func(i int) {
            defer wg.Done()
            mux.Lock()
            ints = append(ints, i)
            mux.Unlock()
        }(i)
    }

    wg.Wait()
    return ints
}

只要是讀寫 ints 前面就放上 Lock,變數就不會被覆蓋,寫完之後就可以使用 Unlock 來解除,更簡單一點,可以使用 defer 來 Unlock。由於丟到 goroutine 方式來平行處理,所以需要使用 WaitGroup 確保全部 goroutine 拿到資料後,才結束 func。

Share memory by communicating

上面的例子可以看到使用了 sync.WaitGroupsync.Mutex 才能完成 goroutine 拿到正確資料,如果是透過 Channel 方式是否可以避免上面提到的問題呢?

func addByShareCommunicate(n int) []int {
    var ints []int
    channel := make(chan int, n)

    for i := 0; i < n; i++ {
        go func(channel chan<- int, order int) {
            channel <- order
        }(channel, i)
    }

    for i := range channel {
        ints = append(ints, i)

        if len(ints) == n {
            break
        }
    }

    close(channel)

    return ints
}

從上面範例可以看到能夠寫入 ints 變數的只有一個 for 迴圈,這就是所謂的建立一個 channel 當作 share memory by communicating,而不是把一個變數丟進 goroutine 進行共享造成錯誤。

上面先使用了 goroutine 來進行寫入 channel,所以可以看到第一個參數使用 chan<- int 確保在此 goroutine 只能寫入 channel,而不能從 channle 讀資料出來。接著後面使用一個 for 迴圈,陸續將 channle 裡面的值讀出來。用了 channel 就再也不需要 WaitGroup 及 Lock 機制,只要確保最後讀取 channel 後,要把 channle 關閉。

Benchmark

我們把上面兩種實作方式做 Benchmark,結果如下:

goos: darwin
goarch: amd64
BenchmarkAddByShareMemory-8        31131   38005 ns/op  2098 B/op  11 allocs/op
BenchmarkAddByShareCommunicate-8   22915   51837 ns/op  2936 B/op   9 allocs/op
PASS

可以發現其實第一種透過 waitGroup + Lock 方式會比用 Channel 效能還好。

結論

可以總結底下兩點是我個人覺得可以參考的依據:

  • 除非是兩個 goruoutine 之間需要交換訊息,否則還是使用一般的 waitGroup + Lock 效能會比較好。不要因為使用 channel 比較潮,而強制在專案內使用。在很多狀況底下一般的 Slice 或 callback 效能會比較好。
  • 如上面所說,使用了大量的 goroutine,中間需要交換資料,這時候就可以使用 Channel 來進行溝通,雖然如同上面的數據,效能也許會差一些,但是後續的 maintain 以及效能瓶頸,都不會是在交換 Channel 上面。

上面程式範例可以在這邊找到

[SQL] 如何從單一資料表取得每個 key 前 n 筆資料

$
0
0

postgres

最近專案需求需要實現單筆資料的版本控制,所以會有一張表 (foo) 專門儲存 key 資料,而有另外一張表 (bar) 專門存 Data 資料,那在 bar 這張表怎麼拿到全部 key 的最新版本資料?底下先看看 schema 範例

-- foo table
DROP TABLE IF EXISTS "foo";
CREATE TABLE `foo` (
  `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
  `name` TEXT NULL,
  `key` TEXT NULL,
  `created_at` DATETIME NULL,
  `updated_at` DATETIME NULL
);

-- bar table
CREATE TABLE `bar` (
  `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 
  `foo_id` INTEGER NULL, 
  `is_deleted` INTEGER NULL, 
  `timestamp` INTEGER NULL, 
  `created_at` DATETIME NULL, 
  `data` TEXT NULL, 
  `memo` TEXT NULL
)

其中 foo 資料表內的 name + key 是唯一值,所以會是一對多狀態,一把 key 會對應到 bar 內的多組資料。而 bar 內的 timestamp 則是用來處理版本控制,每一次的修改就會多出一組新的 timestamp 資料。底下會來介紹該如何取得每一把 key 的前幾筆 data 資料。

使用 UNION 方式

先講資料不多的時候可以透過 UNION 方式解決,如下:

select * from bar where foo_id=1 order by timestamp desc limit 3
UNION
select * from bar where foo_id=2 order by timestamp desc limit 3
UNION
select * from bar where foo_id=3 order by timestamp desc limit 3
.
.
.
select * from bar where foo_id=n order by timestamp desc limit 3

這個做法其實還不預期可以解決版本控制的問題,假設同一筆 foo_id 的資料在每一個 timestamp 版本筆數不一樣,這樣就會噴錯

foo_id timestamp data
1 100 test_01
1 100 test_02
1 100 test_03
1 101 test_01
1 101 test_02
1 101 test_03
1 101 test_04

如果只透過 limit 方式根本拿不到 timestamp 為 101 的資料 (因為有四筆,透過 limit 只能拿到 3 筆)。所以這個解法完全不適合。

使用 rank() 方式

rank() 方式可以在 MySQL, SQLitePostgres 都支援,由於目前我開發模式都是本機使用 SQLite,Production 環境則用 Postgres,所以在寫 SQL 同時都會兼顧是否三者都能並行 (執行開源專案養成的 XD),這時候來實驗看看用 rank 來標記 timestamp:

SELECT bar.*, 
  rank() OVER (PARTITION BY foo_id ORDER BY "timestamp" DESC) as rank
  FROM bar

就會拿到底下資料

foo_id timestamp data rank
1 101 test_01 1
1 101 test_02 1
1 101 test_03 1
1 101 test_04 1
1 100 test_01 2
1 100 test_02 2
1 100 test_03 2

這時候我們要拿 foo_id 為 1 時的資料,就可以透過 rank = 1 方式解決 limit 的問題。接下來需要處理如何拿每一個 foo_id 的最新版本 (timestamp) 資料。假設資料如下:

foo_id timestamp data
1 100 1_test_01
1 101 1_test_01
1 101 1_test_02
2 100 2_test_01
2 101 2_test_02
2 102 2_test_03
3 100 3_test_01
3 103 3_test_02
3 104 3_test_03
3 105 3_test_04

我們需要拿到最新的版本

  • foo_id 為 1 時的 101 版本
  • foo_id 為 2 時的 102 版本
  • foo_id 為 3 時的 105 版本
select bar.* from 
(SELECT bar.*, 
  rank() OVER (PARTITION BY foo_id ORDER BY "timestamp" DESC) as rank
  FROM bar) bar
  where rank = 1

資料如下:

foo_id timestamp data rank
1 101 1_test_01 1
1 101 1_test_02 1
2 102 2_test_03 1
3 105 3_test_04 1

透過 rank = 1 就可以拿到每一筆 foo 的最新版本。接著假設我們想拿到 timestamp 為 102 的版本該如何處理,這時候我們就需要找尋每一筆 foo 的版本為最接近 102。

  • foo_id 為 1 時的 101 版本
  • foo_id 為 2 時的 102 版本
  • foo_id 為 3 時的 100 版本 (100 最今近 102)
select bar.* from 
(SELECT bar.*, 
  rank() OVER (PARTITION BY foo_id ORDER BY "timestamp" DESC) as rank
  FROM bar where "timestamp" <= 102) bar
  where rank = 1

資料如下:

foo_id timestamp data rank
1 101 1_test_01 1
1 101 1_test_02 1
2 102 2_test_03 1
3 100 3_test_01 1

以上就是透過 rank() 來解決資料版本控制問題。如果大家有更好的解法或建議,歡迎在底下留言。

[Go 教學] graceful shutdown with multiple workers

$
0
0

golang logo

在閱讀本文章之前請先預習『用 Go 語言 buffered channel 實作 Job Queue』,本篇會針對投影片 p.26 到 p.56 做詳細的介紹,教大家如何從無到有寫一個簡單的 multiple worker,以及如何處理 grachful shutdown with workers,為什麼要處理 grachful shutdown? 原因是中途手動執行 ctrl + c 或者是部署新版程式都會遇到該如何確保 job 執行完成後才結束 main 函式。

教學影片

教學影片會之後放上,如果對於課程內容有興趣,可以參考底下課程。

關閉 Channel

通常會開一個 Channel 搭配多個 worker 才能達到平行處理,那該如何正確關閉 Channel? 底下看個例子:

func main() {
    ch := make(chan int, 2)
    go func() {
        ch <- 1
        ch <- 2
    }()

    for n := range ch {
        fmt.Println(n)
    }
}

執行上述程式你會發現出現了

fatal error: all goroutines are asleep – deadlock!

原因在於沒有關閉 channel,造成 main 函式一直讀取 channel,但是 channle 裡面已經不會再有值了,就造成主程式 deadlock,避免此問題很簡單

func main() {
    ch := make(chan int, 2)
    go func() {
        ch <- 1
        ch <- 2
        close(ch)
    }()

    for n := range ch {
        fmt.Println(n)
    }
}

除了 close(ch) 之外,另一個方式就將讀取 channel 也丟到 goroutine 內

func main() {
    ch := make(chan int, 2)
    go func() {
        ch <- 1
        ch <- 2
    }()

    go func() {
        for n := range ch {
            fmt.Println(n)
        }
    }()

    time.Sleep(1 * time.Second)
}

了解上述 channel 觀念後,可以來實作底下 consumer 流程

實作 consumer

底下會創建兩個 channel 來實作 consumer,其中 jobsChan 後面會有多個 worker 串接。

// Consumer struct
type Consumer struct {
    inputChan chan int
    jobsChan  chan int
}

func main() {
    // create the consumer
    consumer := Consumer{
        inputChan: make(chan int, 10),
        jobsChan:  make(chan int, poolSize),
    }
}

接著實現 worker 模組

func (c *Consumer) queue(input int) {
    select {
    case c.inputChan <- input:
        log.Println("already send input value:", input)
        return true
    default:
        return false
    }
}

func (c *Consumer) process(num, job int) {
    n := getRandomTime()
    log.Printf("Sleeping %d seconds...\n", n)
    time.Sleep(time.Duration(n) * time.Second)
    log.Println("worker:", num, " job value:", job)
}

func (c *Consumer) worker(num int) {
    log.Println("start the worker", num)
    for {
        select {
        case job := <-c.jobsChan:
            c.process(num, job)
        }
    }
}

func (c Consumer) startConsumer(ctx context.Context) {
    for {
        select {
        case job := <-c.inputChan:
            c.jobsChan <- job
        }
    }
}

const poolSize = 2

func main() {
    // create the consumer
    consumer := Consumer{
        inputChan: make(chan int, 10),
        jobsChan:  make(chan int, poolSize),
    }

    for i := 0; i < poolSize; i++ {
        go consumer.worker(i)
    }

    go consumer.startConsumer(ctx)

    consumer.queue(1)
    consumer.queue(2)
    consumer.queue(3)
    consumer.queue(4)
    consumer.queue(5)
}

由上述程式碼可以看到,都會透過 for select 方式來對 channel 進行讀寫動作。其中 queue 用來將資料丟入 input channel。

Shutdown with Sigterm Handling

接著處理當使用者按下 ctrl + c 或者是容器被移除時 (restart) 該如何接到此訊號?

這時候就需要用到 context

func withContextFunc(ctx context.Context, f func()) context.Context {
    ctx, cancel := context.WithCancel(ctx)
    go func() {
        c := make(chan os.Signal)
        signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
        defer signal.Stop(c)

        select {
        case <-ctx.Done():
        case <-c:
            cancel()
            f()
        }
    }()

    return ctx
}

其中 syscall.SIGINT, syscall.SIGTERM 用來偵測使用者是否按下 ctrl+c 或者是容器被移除時就會執行。所以當開發者按下 ctrl+c 就會直接觸發 cancel(),所以在最前面會使用 context.WithCancel,之後有機會再詳細介紹 context 的使用方式。

由於使用了 context,這樣就可以在每個 func 帶入客製化的 context。需要變動的有 startConsumerworker

func (c Consumer) startConsumer(ctx context.Context) {
    for {
        select {
        case job := <-c.inputChan:
            if ctx.Err() != nil {
                close(c.jobsChan)
                return
            }
            c.jobsChan <- job
        case <-ctx.Done():
            close(c.jobsChan)
            return
        }
    }
}

func (c *Consumer) worker(ctx context.Context, num int) {
    log.Println("start the worker", num)
    for {
        select {
        case job := <-c.jobsChan:
            if ctx.Err() != nil {
                log.Println("get next job", job, "and close the worker", num)
                return
            }
            c.process(num, job)
        case <-ctx.Done():
            log.Println("close the worker", num)
            return
        }
    }
}

這邊要注意的是,當我們按下 ctrl+c 終止 worker 時,理論上會直接到 case <-ctx.Done() 但是實際狀況是有時候會直接在繼續讀取 channel 下一個值。這時候就需要在讀取 channel 後判斷 context 是否已經取消。在 main 最後通常會放一個 channel 來判斷是否需要中斷 main 函式。

func main() {
    finished := make(chan bool)

    ctx := withContextFunc(context.Background(), func() {
        log.Println("cancel from ctrl+c event")
        close(finished)
    })

    <-finished
}

上述完成後,按下 ctrl + c 後,就可以直接執行 close channel,整個主程式都停止,但是這不是我們預期得結果,預期的是需要等到全部的 worker 把正在處理的 Job 完成後,才進行停止才是。

Graceful shutdown with worker

要用什麼方式才可以等到 worker 處理完畢後才結束 main 函式呢?這時候需要用到 sync.WaitGroup

const poolSize = 2

func main() {
    finished := make(chan bool)
    wg := &sync.WaitGroup{}
    wg.Add(poolSize)
}

其中 poolSize 代表的是 worker 數量,接著調整 worker 函式

func (c *Consumer) worker(ctx context.Context, num int, wg *sync.WaitGroup) {
    defer wg.Done()
    log.Println("start the worker", num)
    for {
        select {
        case job := <-c.jobsChan:
            if ctx.Err() != nil {
                log.Println("get next job", job, "and close the worker", num)
                return
            }
            c.process(num, job)
        case <-ctx.Done():
            log.Println("close the worker", num)
            return
        }
    }
}

只有在最前面加上 defer wg.Done(),接著修正 context 的 callback 函式,增加 wg.Wait() 讓 main 函式等到所有的 worker 處理完畢後才關閉 finished channel。

    ctx := withContextFunc(context.Background(), func() {
        log.Println("cancel from ctrl+c event")
        wg.Wait()
        close(finished)
    })

最後在主程式後面加上 <-finished 即可。

const poolSize = 2

func main() {
    finished := make(chan bool)
    wg := &sync.WaitGroup{}
    wg.Add(poolSize)
    // create the consumer
    consumer := Consumer{
        inputChan: make(chan int, 10),
        jobsChan:  make(chan int, poolSize),
    }

    ctx := withContextFunc(context.Background(), func() {
        log.Println("cancel from ctrl+c event")
        wg.Wait()
        close(finished)
    })

    for i := 0; i < poolSize; i++ {
        go consumer.worker(ctx, i, wg)
    }

    <-finished
    log.Println("Game over")
}

最後附上完整的程式碼讓大家測試:

package main

import (
    "context"
    "log"
    "math/rand"
    "os"
    "os/signal"
    "sync"
    "syscall"
    "time"
)

// Consumer struct
type Consumer struct {
    inputChan chan int
    jobsChan  chan int
}

func getRandomTime() int {
    rand.Seed(time.Now().UnixNano())
    return rand.Intn(10)
}

func withContextFunc(ctx context.Context, f func()) context.Context {
    ctx, cancel := context.WithCancel(ctx)
    go func() {
        c := make(chan os.Signal)
        signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
        defer signal.Stop(c)

        select {
        case <-ctx.Done():
        case <-c:
            cancel()
            f()
        }
    }()

    return ctx
}

func (c *Consumer) queue(input int) bool {
    select {
    case c.inputChan <- input:
        log.Println("already send input value:", input)
        return true
    default:
        return false
    }
}

func (c Consumer) startConsumer(ctx context.Context) {
    for {
        select {
        case job := <-c.inputChan:
            if ctx.Err() != nil {
                close(c.jobsChan)
                return
            }
            c.jobsChan <- job
        case <-ctx.Done():
            close(c.jobsChan)
            return
        }
    }
}

func (c *Consumer) process(num, job int) {
    n := getRandomTime()
    log.Printf("Sleeping %d seconds...\n", n)
    time.Sleep(time.Duration(n) * time.Second)
    log.Println("worker:", num, " job value:", job)
}

func (c *Consumer) worker(ctx context.Context, num int, wg *sync.WaitGroup) {
    defer wg.Done()
    log.Println("start the worker", num)
    for {
        select {
        case job := <-c.jobsChan:
            if ctx.Err() != nil {
                log.Println("get next job", job, "and close the worker", num)
                return
            }
            c.process(num, job)
        case <-ctx.Done():
            log.Println("close the worker", num)
            return
        }
    }
}

const poolSize = 2

func main() {
    finished := make(chan bool)
    wg := &sync.WaitGroup{}
    wg.Add(poolSize)
    // create the consumer
    consumer := Consumer{
        inputChan: make(chan int, 10),
        jobsChan:  make(chan int, poolSize),
    }

    ctx := withContextFunc(context.Background(), func() {
        log.Println("cancel from ctrl+c event")
        wg.Wait()
        close(finished)
    })

    for i := 0; i < poolSize; i++ {
        go consumer.worker(ctx, i, wg)
    }

    go consumer.startConsumer(ctx)

    go func() {
        consumer.queue(1)
        consumer.queue(2)
        consumer.queue(3)
        consumer.queue(4)
        consumer.queue(5)
    }()

    <-finished
    log.Println("Game over")
}

[Go 教學] 什麼是 graceful shutdown?

$
0
0

golang logo

我們該如何升級 Web 服務,你會說很簡單啊,只要關閉服務,上程式碼,再開啟服務即可,可是很多時候開發者可能沒有想到現在服務上面是否有正在處理的資料,像是購物車交易?也或者是說背景有正在處理重要的事情,如果強制關閉服務,就會造成下次啟動時會有一些資料上的差異,那該如何優雅地關閉服務,這就是本篇的重點了。底下先透過簡單的 gin http 服務範例介紹簡單的 web 服務

教學影片

如果對於課程內容有興趣,可以參考底下課程。

基本 HTTPD 服務

package main

import (
    "log"
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
)

func main() {
    router := gin.Default()
    router.GET("/", func(c *gin.Context) {
        time.Sleep(5 * time.Second)
        c.String(http.StatusOK, "Welcome Gin Server")
    })

    srv := &http.Server{
        Addr:    ":8080",
        Handler: router,
    }

    // service connections
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatalf("listen: %s\n", err)
    }

    log.Println("Server exiting")
}

上述程式碼在我們寫基本的 web 服務都不會考慮到 graceful shutdown,如果有重要的 Job 在上面跑,我強烈建議一定要加上 Go 在 1.8 版推出的 graceful shutdown 函式,上述程式碼假設透過底下指令執行:

curl -v http://localhost:8080

接著把 server 關閉,就會強制關閉 client 連線,並且噴錯。底下會用 graceful shutdown 來解決此問題。

使用 graceful shutdown

Go 1.8 推出 graceful shutdown,讓開發者可以針對不同的情境在升級過程中做保護,整個流程大致上會如下:

  1. 關閉服務連接埠
  2. 等待並且關閉所有連線

可以看到步驟 1. 會先關閉連接埠,確保沒有新的使用者連上服務,第二步驟就是確保處理完剩下的 http 連線才會正常關閉,來看看底下範例

// +build go1.8

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/gin-gonic/gin"
)

func main() {
    router := gin.Default()
    router.GET("/", func(c *gin.Context) {
        time.Sleep(5 * time.Second)
        c.String(http.StatusOK, "Welcome Gin Server")
    })

    srv := &http.Server{
        Addr:    ":8080",
        Handler: router,
    }

    go func() {
        // service connections
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %s\n", err)
        }
    }()

    // Wait for interrupt signal to gracefully shutdown the server with
    // a timeout of 5 seconds.
    quit := make(chan os.Signal, 1)
    // kill (no param) default send syscall.SIGTERM
    // kill -2 is syscall.SIGINT
    // kill -9 is syscall.SIGKILL but can't be catch, so don't need add it
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    log.Println("Shutdown Server ...")

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    if err := srv.Shutdown(ctx); err != nil {
        log.Fatal("Server Shutdown: ", err)
    }

    log.Println("Server exiting")
}

首先可以看到將 srv.ListenAndServe 直接丟到背景執行,這樣才不會阻斷後續的流程,接著宣告一個 os.Signal 訊號的 Channel,並且接受系統 SIGINT 及 SIGTERM,也就是只要透過 kill 或者是 docker rm 就會收到訊號關閉 quit 通道

<-quit

由上面可知,整個 main func 會被 block 在這地方,假設按下 ctrl + c 就會被系統訊號 (SIGINT 及 SIGTERM) 通知關閉 quit 通道,通道被關閉後,就會繼續往下執行

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
    log.Fatal("Server Shutdown: ", err)
}

最後可以看到 srv.Shutdown 就是用來處理『1. 關閉連接埠』及『2. 等待所有連線處理結束』,可以看到傳了一個 context 進 Shutdown 函式,目的就是讓程式最多等待 5 秒時間,如果超過 5 秒就強制關閉所有連線,所以您需要根據 server 處理的資料時間來決 定等待時間,設定太短就會造成強制關閉,建議依照情境來設定。至於服務 shutdown 後可以處理哪些事情就看開發者決定。

  1. 關閉 Database 連線
  2. 等到背景 worker 處理

可以搭配上一篇提到的『graceful shutdown with multiple workers

[Go 教學] graceful shutdown 搭配 docker-compose 實現 rolling update

$
0
0

golang logo

上一篇作者有提到『什麼是 graceful shutdown?』,本篇透過 docker-compose 方式來驗證 Go 語言的 graceful shutdown 是否可以正常運作。除了驗證之外,單機版 Docker 本身就可以設定 scale 容器數量,那這時候又該如何搭配 graceful shutdown 來實現 rolling update 呢?相信大家對於 rolling update 並不陌生,現在的 kubernetes 已經有實現這個功能,用簡單的指令就可以達到此需求,但是對於沒有在用 k8s 架構的開發者,可能網站也不大,那該如何透過單機本的 docker 來實現呢?底下先來看看為什麼會出現這樣的需求。

假設您有一個 App 服務,需要在單機版上面透過 docker-compose 同時啟動兩個容器,可以透過底下指令一次完成:

docker-compose up -d --scale app=2

其中 app 就是在 YAML 裡面的服務名稱。這時候可以看到背景就跑了兩個容器,接著要升級 App 服務,您會發現在下一次上述指令,可以看到 docker 會先把兩個容器先停止,但是容器被停止前會透過 graceful shutdown 確認背景的服務或工作需要完成結束,才可以正確停止容器並且移除,最後再啟動新的 App 容器。這時候你會發現 App 服務被終止了幾分鐘時間完全無法運作。底下來介紹該如何解決此問題,以及驗證 graceful shutdown 是否可以正常運作

教學影片

如果對於課程內容有興趣,可以參考底下課程。

graceful shutdown 範例

先簡單寫個 Go 範例:

package main

import (
    "context"
    "flag"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

var (
    listenAddr string
)

func main() {
    flag.StringVar(&listenAddr, "listen-addr", ":8080", "server listen address")
    flag.Parse()

    logger := log.New(os.Stdout, "http: ", log.LstdFlags)

    router := http.NewServeMux() // here you could also go with third party packages to create a router
    // Register your routes
    router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(15 * time.Second)
        w.WriteHeader(http.StatusOK)
    })

    router.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(10 * time.Second)
        w.WriteHeader(http.StatusOK)
    })

    server := &http.Server{
        Addr:         listenAddr,
        Handler:      router,
        ErrorLog:     logger,
        ReadTimeout:  30 * time.Second,
        WriteTimeout: 30 * time.Second,
        IdleTimeout:  30 * time.Second,
    }

    done := make(chan bool, 1)
    quit := make(chan os.Signal, 1)

    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

    go func() {
        <-quit
        logger.Println("Server is shutting down...")

        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancel()

        if err := server.Shutdown(ctx); err != nil {
            logger.Fatalf("Could not gracefully shutdown the server: %v\n", err)
        }
        close(done)
    }()

    logger.Println("Server is ready to handle requests at", listenAddr)
    if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        logger.Fatalf("Could not listen on %s: %v\n", listenAddr, err)
    }

    <-done
    logger.Println("Server stopped")
}

上面程式可以知道,直接打 / 就會等待 15 秒後才能拿到回應

curl -v -H Host:app.docker.localhost http://127.0.0.1:8088

準備 docker 環境

準備 dockerfile

# build stage
FROM golang:alpine AS build-env
ADD . /src
RUN cd /src && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app

# final stage
FROM centurylink/ca-certs
COPY --from=build-env /src/app /

EXPOSE 8080

ENTRYPOINT ["/app"]

準備 docker-compose.yml,使用 Traefik v2 版本來做 Load balancer。

version: '3'

services:
  app:
    image: go-training/app
    restart: always
    logging:
      options:
        max-size: "100k"
        max-file: "3"
    labels:
      - "traefik.http.routers.app.rule=Host(`app.docker.localhost`)"

  reverse-proxy:
    # The official v2.0 Traefik docker image
    image: traefik:v2.0
    # Enables the web UI and tells Traefik to listen to docker
    command: --api.insecure=true --providers.docker
    ports:
      # The HTTP port
      - "8088:80"
      # The Web UI (enabled by --api.insecure=true)
      - "8080:8080"
    volumes:
      # So that Traefik can listen to the Docker events
      - /var/run/docker.sock:/var/run/docker.sock

可以看到 8088 port 會是入口,app.docker.localhost 會是 app 網域名稱。

驗證 graceful shutdown

啟動全部服務,App 及 Traefik 都有被正式啟動

docker-compose up -d --scale app=2

接下來先修改原本的 Go 範例,在編譯一次把 Image 先產生好。另外開兩個 console 頁面直接下

curl -v -H Host:app.docker.localhost http://127.0.0.1:8088

會發現 curl 會等待 15 秒才能拿到回應,這時候直接下

docker-compose up -d --scale app=2

就可以看到

app_2   | http: 2020/02/08 14:06:20 Server is shutting down... 
app_2   | http: 2020/02/08 14:06:20 Server stopped
app_1   | http: 2020/02/08 14:06:20 Server is shutting down...
app_1   | http: 2020/02/08 14:06:20 Server stopped

這代表 graceful shutdown 可以正常運作,確保 app 連線及後續處理的動作可以正常被執行。

用 docker-compose 執行 rolling update

從上面可以看到,當執行了

docker-compose up -d --scale app=2

docker 會把目前的容器都全部停止,假設這時候都有重要的工作需要繼續執行,但是 graceful shutdown 已經將連接埠停止,造成使用者已經無法連線,這問題該如何解決呢?其實不難,只需要修正幾個指令就可以做到。由於 docker-compose up -d 會先將所有容器先停止,造成無法連線,這時候需要使用 --no-recreate flag 來避免這問題

docker-compose up -d --scale app=3 --no-recreate

將數量 + 1 的意思就是先啟動一個新的容器用來接受新的連線,接著將舊的容器移除:

docker stop -t 30 \
  $(docker ps --format "table {{.ID}}  {{.Names}}  {{.CreatedAt}}" | \
  grep app | \
  sort -k2 | \
  awk -F  "  " '{print $1}' | head -2)

其中 -t 30 一定要設定,預設會是 10 秒相當短,也就是 10 秒容器沒結束就自動 kill 了,後面的 head -2 代表移除舊的容器,原本是開兩台,就需要停止兩台。接著將已經停止的容器砍掉:

docker container prune -f

現在正在執行的容器只剩下一台,故還需要透過 scale 將不足的容器補上:

docker-compose up -d --scale app=2 --no-recreate

完成上述步驟後,就可以確保服務不會中斷。如果有更好的解法歡迎大家提供。


使用 Docker 五分鐘安裝好 Gitea (自架 Git Hosting 最佳選擇)

$
0
0

Gitea

Gitea 在本週發佈了 1.11.0 版本,本篇就使用 Docker 方式來安裝 Gitea,執行時間不會超過五分鐘。Gitea 是一套開源的 Git Hosting,除了 Gitea 之外,您可以選擇 GitHub 或自行安裝 GitLab,但是我為什麼選擇 Gitea 呢?原因有底下幾點

  1. Gitea 是開源專案,全世界的開發者都可以進行貢獻
  2. Gitea 是 Go 語言所開發,啟動速度超快
  3. Gitea 開源社區非常完整,每年固定挑選三位為主要負責人
  4. Gitea 可以使用執行檔或 Docker 方式進行安裝

Gitea 目前發展方向就是自己服務自己,大家可能有發現原本在 GitHub 上面的 Repository 已經全面轉到 Gitea 自主服務了,這也代表著未來會全面轉過去,只是時間上的問題。Gitea 目前的功能其實相當完整,大家有興趣可以看這張比較表,新創團隊我都強烈建議使用 Gitea。

教學影片

如果對於課程內容有興趣,可以參考底下課程。

安裝方式

透過 docker-compose 方式安裝會是最快的,大家可以參考此 Repository

version: "2"

networks:
  gitea:
    external: false

services:
  server:
    image: gitea/gitea:1.11.0
    environment:
      - USER_UID=${USER_UID}
      - USER_GID=${USER_GID}
      - SSH_PORT=2000
      - DISABLE_SSH=true
      - DB_TYPE=mysql
      - DB_HOST=db:3306
      - DB_NAME=gitea
      - DB_USER=gitea
      - DB_PASSWD=gitea
    restart: always
    networks:
      - gitea
    volumes:
      - gitea:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "4000:3000"
      - "2000:22"

  db:
    image: mysql:5.7
    restart: always
    environment:
      - MYSQL_ROOT_PASSWORD=gitea
      - MYSQL_USER=gitea
      - MYSQL_PASSWORD=gitea
      - MYSQL_DATABASE=gitea
    networks:
      - gitea
    volumes:
      - mysql:/var/lib/mysql

volumes:
  gitea:
    driver: local
  mysql:
    driver: local

由上面可以看到只有啟動 Gitea + MySQL 服務就完成了,啟動時間根本不用 10 秒鐘,打開瀏覽器就可以看到安裝畫面了。

用 Drone 部署靜態檔案到 GitHub Pages

$
0
0

新課程上架:『Docker 容器實用實戰』目前特價 $800 TWD,優惠代碼『20200222』,也可以直接匯款(價格再減 100),如果想搭配另外兩門課程合購可以透過 FB 聯絡我

GitHub 提供一個非常方便的功能,就是可以將靜態檔案部署在 GitHub 上,基本上開發者不用負擔任何 Host 費用,就可以使用靜態檔案來做 Demo 介紹,或者是文件系統。而本篇將教您如何用 Drone 來自動化部署靜態檔案到 GitHub 上。作者直接用 Vue.js 來介紹整個流程。

準備 Vue.js 環境

這邊就不多著墨了,透過 npm build 可以在本機端將靜態檔案編譯到 dist 目錄內。而 GitHub Pages 預設的 domain 會是

user_id.github.io/project_name

假設專案的 GitHub URL 為

github.com/go-training/vue-gh-pages-demo

就可以知道 user id 是 go-training 那 repo 名稱為 vue-gh-pages-demo,那 GitHub 提供的 URL 就會是

https://go-training.github.io/vue-gh-pages-demo

可以看出來會有一個 sub folder 跑出來,因為在同一個 Org 或 User 底下會有很多 repo,故一定要這樣區分。這時候在編譯 Vue.js 專案時就需要使用不同的設定,請打開 vue.config.js

module.exports = {
  assetsDir: 'assets',
  publicPath: process.env.NODE_ENV === 'production'
  ? '/vue-gh-pages-demo/'
  : '/',
};

從上面可以看到當開發者需要部署到 GitHub 時,就可以動態將 index.html 內的靜態檔案路徑換成 sub folder 方式,而不影響本機端開發。完整程式範例可以參考這邊

搭配 Drone 自動化部署

由於 GitHub Page 預設是讀 gh-pages 分支,故需要先將此分支建立起來,後續才可以正常部署,請參照底下 .drone.yml 內容

---
kind: pipeline
name: testing

platform:
  os: linux
  arch: amd64

steps:
- name: release
  image: node:13
  environment:
    NODE_ENV: gh-pages
  commands:
  - yarn install
  - yarn build

- name: publish
  image: plugins/gh-pages
  settings:
    username:
      from_secret: username
    password:
      from_secret: password
    pages_directory: dist

其中 username 跟 password 會是 GitHub 的帳號密碼,但是密碼部分可以透過 GitHub 的 Personal Access token 來產生,這樣就不用給真的密碼了。

設定 custom domain

其實會發現使用 sub folder 其實很不方便,所以我個人都習慣使用 custome domain 方式來配置

GitHub 也提供個人網域的 https 憑證,所以像是各大 Conference 如果沒有什麼後端需求,其實都可以直接放到這上面,這樣還可以省下不少人力,及維護主機的成本。

完整程式範例可以參考這邊

用五分鐘安裝好 Drone 搭配 GitHub 自動化環境

$
0
0

之前寫過一篇『用 10 分鐘安裝好 Drone 搭配 GitLab』。團隊內還沒導入自動化 CI/CD 測試部署環境的朋友們,可以來嘗試看看用 Go 語言打造的 Drone CI/CD 開源專案,不用五分鐘的時間就可以在您的電腦上安裝好 CI/CD 的流程,真的是簡單到不行,只要一個 docker-compose 檔案就可以完成架設了。

教學影片

如果對於課程內容有興趣,可以參考底下課程。

如果需要搭配購買請直接透過 FB 聯絡我,直接匯款(價格再減 100

Go Modules 處理私有 GIT Repository 流程

$
0
0

golang

Golang1.14 正式說明可以將 Go Modules 用在正式環境上了,還沒換上 Go Modules 的團隊,現在可以開始轉換了,轉換方式也相當容易啦,只要在原本的專案底下執行底下指令,就可以無痛轉移

go mod init project_path
go mod tidy

假設專案內有用到私有 Git Repository 該怎麼解決了?現在 go mod 會預設走 proxy.golang.org 去抓取最新的資料,但是要抓私有的,就需要透過其他方式:

go env -w GOPRIVATE=github.com/appleboy

上面代表告訴 go 指令,只要遇到 github.com/appleboy 就直接讀取,不需要走 Proxy 流程。拿 GitHub 當作範例,在本機端開發該如何使用?首先要先去申請 Personal Access Token,接著設定 Git

git config --global url."https://$USERNAME:$ACCESS_TOKEN@github.com".insteadOf "https://github.com"

其中 Username 就是 GitHub 帳號,Access token 就是上面的 Personal Access Token

教學影片

影片只上傳到 Udemy,如果對於課程內容有興趣,可以參考底下課程。

如果需要搭配購買請直接透過 FB 聯絡我,直接匯款(價格再減 100

搭配 Drone CI/CD

在串 CI/CD 的流程第一步就是下載 Go 套件,這時候也需要將上述步驟重新操作一次。首先撰寫 main.go

package main

import (
    "fmt"

    hello "github.com/appleboy/golang-private"
)

func main() {
    fmt.Println("get private module")
    fmt.Println("foo:", hello.Foo())
}

其中 golang-private 是一個私有 repository。接著按照本機版的做法,複製到 Drone 的 YAML 檔案。

steps:
- name: build
  image: golang:1.14
  environment:
    USERNAME:
      from_secret: username
    ACCESS_TOKEN:
      from_secret: access_token
  commands:
  - go env -w GOPRIVATE=github.com/$USERNAME
  - git config --global url."https://$USERNAME:$ACCESS_TOKEN@github.com".insteadOf "https://github.com"
  - go mod tidy
  - go build -o main .

使用 Dockerfile 編譯

現在 Docker 支援 Multiple Stage,基本上很多部署方式都朝向一個 Dockerfile 解決,當然 Go 語言也不例外,先看看傳統寫法:

# Start from the latest golang base image
FROM golang:1.14 as Builder

RUN GOCACHE=OFF

RUN go env -w GOPRIVATE=github.com/appleboy

# Set the Current Working Directory inside the container
WORKDIR /app

# Copy everything from the current directory to the Working Directory inside the container
COPY . .

ARG ACCESS_TOKEN
ENV ACCESS_TOKEN=$ACCESS_TOKEN

RUN git config --global url."https://appleboy:${ACCESS_TOKEN}@github.com".insteadOf "https://github.com"

# Build the Go app
RUN go build -o main .

CMD ["/app/main"]

從上面可以看到一樣在 Docker 使用 git 方式讀取 Private Repository,但是你會發現上面編譯出來的 Image 有兩個問題,第一個就是檔案大小特別大,當然你會說那就用 alpine 也可以啊,是沒錯,但是還是很大。另一個最重要的問題就是暴露了 ACCESS_TOKEN,先在本機端直接執行 docker build。

docker build \
  --build-arg ACCESS_TOKEN=test1234 \
  -t appleboy/golang-module-private .

接著使用底下指令可以直接查到每個 Layer 的下了什麼指令以及帶入什麼參數?

docker history --no-trunc \
  appleboy/golang-module-private

會發現有一行可以看到您申請的 ACCESS_TOKEN

/bin/sh -c #(nop)  ENV ACCESS_TOKEN=xxxxxxx

如果您的 docker image 放在 docker hub 上面並且是公開的,就會直接被拿走,至於拿走做啥就不用說了吧,等於你的 GitHub 帳號被盜用一樣。要用什麼方式才可以解決這問題呢?很簡單就是透過 multiple stage

# Start from the latest golang base image
FROM golang:1.14 as Builder

RUN GOCACHE=OFF

RUN go env -w GOPRIVATE=github.com/appleboy

# Set the Current Working Directory inside the container
WORKDIR /app

# Copy everything from the current directory to the Working Directory inside the container
COPY . .

ARG ACCESS_TOKEN
ENV ACCESS_TOKEN=$ACCESS_TOKEN

RUN git config --global url."https://appleboy:${ACCESS_TOKEN}@github.com".insteadOf "https://github.com"

# Build the Go app
RUN go build -o main .

FROM scratch

COPY --from=Builder /app/main /

CMD ["/main"]

用 multiple stage 不但可以將 Image size 減到最小,還可以防禦特定 ARGS 被看到破解。透過上述方式就可以成功讀取私有 git repository,並且達到最佳的安全性。

整合 Drone 自動化上傳 Docker Image

- name: build-image
  image: plugins/docker
  environment:
    ACCESS_TOKEN:
      from_secret: access_token
  settings:
    username:
      from_secret: username
    password:
      from_secret: password
    repo: appleboy/golang-module-private
    build_args_from_env:
      - ACCESS_TOKEN

上面簡單的透過 environment 傳遞 ACCESS_TOKEN 進到 ARGS 設定。用 Drone 其實就很方便自動編譯並且上傳到 Docker Hub 或是自家的 Private Registry。

停止 Go 服務前先處理完 Worker 內的 Job

$
0
0

golang logo

在閱讀本文章之前,作者有寫過一篇『graceful shutdown with multiple workers』此篇文章介紹了可以在服務停止前做一些正確的 Shutdown 流程,像是處理 Http Handler 或關閉資料庫連線等等,假設有服務內有實作 Worker 處裡多個 Job,那該如何等到全部的 Job 都執行完畢才正確關閉且刪除服務 (使用 Docker) 呢?底下是整個運作流程:

遇到問題

當服務被關閉或者強制使用 ctrl + c 停止,則應該等到所有的 worker 都完成全部 Job 才停止服務。先來看看之前第一版的寫法有什麼問題,當開發者按下 ctrl + c 就會送出 cancel() 訊號,接著看看 worker 原先是怎麼寫的?

func (c *Consumer) worker(ctx context.Context, num int, wg *sync.WaitGroup) {
    defer wg.Done()
    log.Println("start the worker", num)
    for {
        select {
        case job := <-c.jobsChan:
            if ctx.Err() != nil {
                log.Println("get next job", job, "and close the worker", num)
                return
            }
            c.process(num, job)
        case <-ctx.Done():
            log.Println("close the worker", num)
            return
        }
    }
}

假設現在有 10 個 job 同時進來,有四個 worker 同時處理,接著按下 ctrl + c 後,就會觸發 ctx.Done() channel,因為 Select 接受兩個 channel,開發者不能預期哪一個先觸發,但是假設 jobsChan 還有其他 job 需要處理,就會被程式終止。該如何解決此問題呢?繼續往下看

改寫 worker

其實很簡單只要將 worker 部分重新改寫即可,不要使用 select 方式:

func (c *Consumer) worker(num int, wg *sync.WaitGroup) {
    defer wg.Done()
    log.Println("start the worker", num)

    for job := range c.jobsChan {
        c.process(num, job)
    }
}

使用 for 方式來讀取 jobsChan,這邊就會等到 channle 完全為空的時候才會結束 for 迴圈,所以有多個 worker 同時讀取 jobsChan。for 結束後,才會觸發 wg.Done() 告訴主程式此 worker 已經完成所以 Job 可以關閉了。

心得

看專案的需求來決定是要立即停止 worker 還是要等到所有的 Job 都處理完畢才結束。兩種方式寫法不同,差異點就在前者需要再 worker 裡面處理兩個 channel,後者只需要透過 for 迴圈方式來將 job channel 全部讀出後才結束。

影片分享

如果對於課程內容有興趣,可以參考底下課程。要合購多個課程,請直接私訊 FB。直接匯款可以享受 100 元折扣。

Docker 推出官方 GitHub Actions 套件

$
0
0

cover

去年 GitHub 推出 Actions,就有不少開發者相繼把 CI/CD 流程內會使用到的 Plugin 都丟到 Marktetplace,而在這 Docker 容器時代,肯定是需要用自動化上傳容器到 Docker Registry,而官方也在上週正式釋出第一版 GitHub Actions,雖然在 Marktet 尚有不少開發者已經有實現了此功能,但是官方既然推出了,就採用官方的套件會比較適合。底下我們來看看如何使用 Docker 推出的 GitHub Aciton 來自動化上傳 Docker Image。除了介紹如何使用 GitHub Action 上傳 Image 外,我也會拿 DroneDocker Plugin 來進行比較。

如何使用 GitHub Action 上傳 Image

首先準備用 Go 語言服務來當作範例,底下是 Dockerfile 設定,本篇的程式碼的都可以在這邊找到

FROM golang:1.14-alpine

LABEL maintainer="Bo-Yi Wu <appleboy.tw@gmail.com>"

RUN apk add bash ca-certificates git gcc g++ libc-dev
WORKDIR /app
# Force the go compiler to use modules
ENV GO111MODULE=on
# We want to populate the module cache based on the go.{mod,sum} files.
COPY go.mod .
COPY go.sum .
COPY main.go .

ENV GOOS=linux
ENV GOARCH=amd64
RUN go build -o /app -tags netgo -ldflags '-w -extldflags "-static"' .

CMD ["/app"]

Docker 詳細的設定方式可以參考此文件

- name: build and push image
  uses: docker/build-push-action@v1
  with:
    username: ${{ secrets.DOCKER_USERNAME }}
    password: ${{ secrets.DOCKER_PASSWORD }}
    repository: appleboy/gin-docker-demo
    dockerfile: Dockerfile
    always_pull: true
    tags: latest

由於我們是拿 docker hub 當作範例,故不需要指定 registry,假設您只要有下 git tag 才做上傳的話,可以使用

- name: build and push image
  uses: docker/build-push-action@v1
  with:
    username: ${{ secrets.DOCKER_USERNAME }}
    password: ${{ secrets.DOCKER_PASSWORD }}
    repository: appleboy/gin-docker-demo
    dockerfile: Dockerfile
    tag_with_ref: true
    push: ${{ startsWith(github.ref, 'refs/tags/') }}

其實設定不難,整個過程的 Log 可以看這邊。底下來介紹 Drone 的使用方式

用 Drone 上傳 Docker Image

GitHub Actions 的版本跟 Drone 的 Plugin 共通點都是用 CLI 完成全部指令,也全都用 Go 語言打造,所以設定方式其實蠻雷同的。

- name: publish
  pull: always
  image: plugins/docker:linux-amd64
  settings:
    auto_tag: true
    cache_from: appleboy/gin-docker-demo
    daemon_off: false
    dockerfile: Dockerfile
    password:
      from_secret: docker_password
    repo: appleboy/gin-docker-demo
    username:
      from_secret: docker_username
  when:
    event:
      exclude:
      - pull_request

兩邊設定完成後,可以透過兩邊的 Log 方式,為什麼 Drone 只需要不到半分鐘的時間就可以執行完畢,而在 GitHub Actions 則是需要一分鐘以上,原因在於 Drone 支援了 --cache-from,不了解的可以直接參考『在 docker-in-docker 環境中使用 cache-from 提升編譯速度』,大概的意思就是在 docker build 之前,先把最新版本的 Image 下載下來,這時候在編譯的時候就會找到相同的 docker layer 而進行 Cache 動作。不過別擔心,為了能讓 GitHub Action 享有這個機制,我也發了 PR 來支援此參數,等到官方審核通過就可以使用了。

用 GitHub Actions 上傳 Docker Image 到 AWS ECR

$
0
0

最近正打算使用 GitHub Actions 來串接 AWS 服務 (ECR + ECS),上網找了一堆 ECR 套件,發現就連 AWS 官方都只有實作 Login 進 ECR,後面編譯跟上傳動作就需要自己寫,可以看看底下是 AWS 官方套件的範例:

    - name: Login to Amazon ECR
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v1

    - name: Build, tag, and push image to Amazon ECR
      env:
        ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        ECR_REPOSITORY: my-ecr-repo
        IMAGE_TAG: ${{ github.sha }}
      run: |
        docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
        docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG

    - name: Logout of Amazon ECR
      if: always()
      run: docker logout ${{ steps.login-ecr.outputs.registry }}

覺得蠻神奇的是為什麼不把 Plugin 寫更完整些,讓使用者不用再執行 docker 指令,所以我直接把 Drone 官方套件直接改寫支援 GitHub Actions 服務,詳細的操作文件可以參考這邊

教學影片

如果對於課程內容有興趣,可以參考底下課程。

如果需要搭配購買請直接透過 FB 聯絡我,直接匯款(價格再減 100

使用方式

本篇會使用兩種 CI/CD 工具,分別是 DroneGitHub Actions,詳細檔案內容可以參考這邊。底下是使用 Drone CI/CD:

- name: publish
  pull: always
  image: plugins/ecr
  settings:
    access_key:
      from_secret: aws_access_key_id
    secret_key:
      from_secret: aws_secret_access_key
    repo: api-sample
    region: ap-northeast-1
    registry:
      from_secret: registry
    auto_tag: true
    daemon_off: false
    dockerfile: Dockerfile
  when:
    event:
      exclude:
      - pull_request

底下是使用 GitHub Actions

build:
  name: upload image
  runs-on: ubuntu-latest
  steps:
  - uses: actions/checkout@master
  - name: upload image to ECR
    uses: appleboy/docker-ecr-action@v0.0.2
    with:
      access_key: ${{ secrets.aws_access_key_id }}
      secret_key: ${{ secrets.aws_secret_access_key }}
      registry: ${{ secrets.registry }}
      cache_from: ${{ secrets.cache }}
      repo: api-sample
      region: ap-northeast-1

兩種使用方式都是一樣的,會用 Drone CI/CD,那使用 GitHub Actions 也不會有問題,另外還支援了 cache_from,省下了一點部署的時間,時間取決於跑的專案 Image 大小了。


[Go 語言] 從 graphql-go 轉換到 gqlgen

$
0
0

golang logo

相信各位開發者對於 GraphQL 帶來的好處已經非常清楚,如果對 GraphQL 很陌生的朋友們,可以直接參考之前作者寫的一篇『Go 語言實戰 GraphQL』,內容會講到用 Go 語言實戰 GraphQL 架構,教開發者如何撰寫 GraphQL 測試及一些開發小技巧,不過內容都是以 graphql-go 框架為主。而本篇主題會講為什麼我從 graphql-go 框架轉換到 gqlgen

前言

我自己用 graphql-go 寫了一些專案,但是碰到的問題其實也不少,很多問題都可以在 graphql-go 專案的 Issue 列表內都可以找到,雖然此專案的 Star 數量是最高,討論度也是最高,如果剛入門 GraphQL,需要練習,用這套見沒啥問題,比較資深的開發者,就不建議選這套了,先來看看功能比較圖

其中有幾項痛點是讓我主要轉換的原因:

  1. 效能考量
  2. 功能差異
  3. schema first
  4. 強型別
  5. 自動產生程式碼

底下一一介紹上述特性

效能考量

我自己建立效能 Benchamrk 來比較市面上幾套 GraphQL 套件 golang-graphql-benchmark

  • graphql-go/graphql version: v0.7.9
  • playlyfe/go-graphql version: v0.0.0-20191219091308-23c3f22218ef
  • graph-gophers/graphql-go version: v0.0.0-20200207002730-8334863f2c8b
  • samsarahq/thunder version: v0.5.0
  • 99designs/gqlgen version: v0.11.3
Requests/sec
graphql-go 19004.92
graph-gophers 44308.44
thunder 40994.33
gqlgen 49925.73

由上面可以看到光是一個 Hello World 範例,最後的結果由 gqlgen 勝出,現在討論度比較高的也只有 gqlgen 跟 grapgql-go,效能上面差異頗大。這算是我轉過去的最主要原因之一。

功能差異

幾個重點差異,底下看看比較圖:

  1. Type Safety
  2. Type Binding
  3. Upload FIle

等蠻多細部差異,graphql-go 目前不支持檔案上傳,所以還是需要透過 RESTFul API 方式上傳,但是已經有人提過 Issue 且發了 PR, 作者看起來沒有想處理這題。就拿上傳檔案當做例子,在 gqlgen 寫檔案上傳相當容易,先寫 schema

"The `Upload` scalar type represents a multipart file upload."
scalar Upload

"The `File` type, represents the response of uploading a file."
type File {
  name: String!
  contentType: String!
  size: Int!
  url: String!
}

就可以直接在 resolver 使用:

type File struct {
    Name        string
    Size        int
    Content     []byte
    ContentType string
}

func (r *mutationResolver) getFile(file graphql.Upload) (*File, error) {
    content, err := ioutil.ReadAll(file.File)
    if err != nil {
        return nil, errors.EBadRequest(errorUploadFile, err)
    }

    contentType := ""
    kind, _ := filetype.Match(content)
    if kind != filetype.Unknown {
        contentType = kind.MIME.Value
    }

    if contentType == "" {
        contentType = http.DetectContentType(content)
    }

    return &File{
        Name:        file.Filename,
        Size:        int(file.Size),
        Content:     content,
        ContentType: contentType,
    }, nil
}

Schema first

後端設計 API 時需要針對使用者情境及 Database 架構來設計 GraphQL Schema,詳細可以參考 Schema Definition Language。底下可以拿使用者註冊來當做例子:

enum EnumGender {
  MAN
  WOMAN
}

# Input Types
input createUserInput {
  email: String!
  password: String!
  doctorCode: String
}

type createUserPayload {
  user: User
  actCode: String
  digitalCode: String
}

# Types
type User {
  id: ID
  email: String!
  nickname: String
  isActive: Boolean
  isFirstLogin: Boolean
  avatarURL: String
  gender: EnumGender
}

type Mutation {
  createUser(input: createUserInput!): createUserPayload
}

除了可以先寫 Schema 之外,還可以根據不同情境的做分類,將一個完整的 Schema 拆成不同模組,這個在 gqlgen 都可以很容易做到。

resolver:
  layout: follow-schema
  dir: graph

之後 gqlgen 會將目錄結構產生如下

user.graphql
user.resolver.go
cart.graphql
cart.resolver.go

開發者只要將相對應的 resolver method 實現出來就可以了。

強型別

如果有在寫 graphql-go 就可以知道該如何取得使用者 input 參數,在 graphql-go 使用的是 map[string]interface{} 型態,要正確拿到參數值,就必須要轉換型態

username := strings.ToLower(p.Args["username"].(string))
password := p.Args["password"].(string)

多了一層轉換相當複雜,而 gqlgen 則是直接幫忙轉成 struct 強型別

CreateUser(ctx context.Context, input model.CreateUserInput)

其中 model.CreateUserInput 就是完整的 struct,而並非是 map[string]interface{},在傳遞參數時,就不用多寫太多 interface 轉換,完整的註冊流程可以參考底下:

func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*model.CreateUserPayload, error) {
    resp, err := api.CreateUser(r.Config, api.ReqCreateUser{
        Email:      input.Email,
        Password:   input.Password,
    })

    if err != nil {
        return nil, err
    }

    return &model.CreateUserPayload{
        User:        resp.User,
        DigitalCode: convert.String(resp.DigitalCode),
        ActCode:     convert.String(resp.ActCode),
    }, nil
}

自動產生代碼

要維護欄位非常多的 Schema 相當不容易,在 graphql-go 每次改動欄位,都需要開發者自行修改,底下是 user type 範例:

var userType = graphql.NewObject(graphql.ObjectConfig{
    Name:        "UserType",
    Description: "User Type",
    Fields: graphql.Fields{
        "id": &graphql.Field{
            Type: graphql.ID,
        },
        "email": &graphql.Field{
            Type: graphql.String,
        },
        "username": &graphql.Field{
            Type: graphql.String,
        },
        "name": &graphql.Field{
            Type: graphql.String,
        },
        "isAdmin": &graphql.Field{
            Type: graphql.Boolean,
            Resolve: func(p graphql.ResolveParams) (interface{}, error) {
                source := p.Source
                o, ok := source.(*model.User)

                if !ok {
                    return false, nil
                }

                return o.CheckAdmin(), nil
            },
        },
        "isNewcomer": &graphql.Field{
            Type: graphql.Boolean,
        },
        "createdAt": &graphql.Field{
            Type: graphql.DateTime,
        },
        "updatedAt": &graphql.Field{
            Type: graphql.DateTime,
        },
    },
})

上面這段程式碼是要靠開發者自行維護,只要有任何異動,都需要手動自行修改,但是在 gqlgen 就不需要了,你只要把 schema 定義完整後,如下:

type User {
  id: ID
  email: String!
  username: String
  isAdmin: Boolean
  isNewcomer: Boolean
  createdAt: Time
  updatedAt: Time
}

在 console 端下 go run github.com/99designs/gqlgen,就會自動將代碼生成完畢。你也可以將 User 綁定在開發者自己定義的 Model 層級。

models:
  User:
    model: pkg/model.User

之後需要新增任何欄位,只要在 pkg/model.User 提供相對應的欄位或 method,重跑一次 gqlgen 就完成了。省下超多開發時間。

心得

其實 graphql-go 雷的地方不只有這些,還有很多地方沒有列出,但是上面的 gqlgen 優勢,已經足以讓我轉換到新的架構上。而在專案新的架構上,也同時具備 RESTFul API + GraphQL 設計,如果有時間再跟大家分享這部分。

使用 Docker BuildKit 加速編譯 Image

$
0
0

docker buildkit

程式碼範例請看這邊

之前就有看到 Docker 推出 BuildKit 功能,這次跟大家介紹什麼是 BuildKit。現在部署編譯流程肯定都會用到 Docker,不管測試及部署都盡量在 Docker 內實現,來做到環境隔離,但是要怎麼縮短 Docker 在編譯 Image 時間,這又是另外的議題,本篇跟大家介紹一個實驗性的功能就是 BuildKit,原始碼可以參考這邊,希望未來這實驗性的功能可以正式納入 Docker 官方,網路上其實可以找到很多方式來做 Docker Layer 的 Cache,我個人最常用的就是 --cache-from 機制,可以適用在任何 CI/CD 流程,詳細說明可以參考這篇『在 docker-in-docker 環境中使用 cache-from 提升編譯速度』,下面使用到的程式碼都可以直接參考此 Repository,我還是使用 Go 語言當作參考範例。

事前準備

由於 BuildKit 是實驗性的功能,預設安裝好 Docker 是不會啟動這功能。目前只有支援編譯 Linux 容器。請透過底下方式來啟動:

DOCKER_BUILDKIT=1 docker build .

下完指令後,你會發現整個 output 結果不太一樣了,介面變得比較好看,也看到每個 Layer 編譯的時間

[+] Building 0.1s (15/15) FINISHED                                                                                     
 => [internal] load .dockerignore                                                                                 0.0s
 => => transferring context: 2B                                                                                   0.0s
 => [internal] load build definition from Dockerfile                                                              0.0s
 => => transferring dockerfile: 545B                                                                              0.0s
 => [internal] load metadata for docker.io/library/golang:1.14-alpine                                             0.0s
 => [1/10] FROM docker.io/library/golang:1.14-alpine                                                              0.0s
 => [internal] load build context                                                                                 0.0s
 => => transferring context: 184B                                                                                 0.0s
 => CACHED [2/10] RUN apk add bash ca-certificates git gcc g++ libc-dev                                           0.0s
 => CACHED [3/10] WORKDIR /app                                                                                    0.0s
 => CACHED [4/10] COPY go.mod .                                                                                   0.0s
 => CACHED [5/10] COPY go.sum .                                                                                   0.0s
 => CACHED [6/10] RUN go mod download                                                                             0.0s
 => CACHED [7/10] COPY main.go .                                                                                  0.0s
 => CACHED [8/10] COPY foo/foo.go foo/                                                                            0.0s
 => CACHED [9/10] COPY bar/bar.go bar/                                                                            0.0s
 => CACHED [10/10] RUN go build -o /app -v -tags netgo -ldflags '-w -extldflags "-static"' .                      0.0s
 => exporting to image                                                                                            0.0s
 => => exporting layers                                                                                           0.0s
 => => writing image sha256:6cc56539b3191d5efd87fb4d05181993d013411299b5cefb74047d2447b4d0c9                      0.0s
 => => naming to docker.io/appleboy/demo                                                                          0.0s

如果要詳細的編譯步驟,請加上 --progress=plain,就可以看到詳細的過程。其實我覺得重點在每個步驟都實際追加了時間,對於在開發上或者是 CI/CD 的流程上都相當有幫助。另外可以在 docker daemon 加上 config 就可以不用加上 DOCKER_BUILDKIT 環境變數

{
  "debug": true,
  "experimental": true,
  "features": {
    "buildkit": true
  }
}

請記得重新啟動 Docker 讓新的設定生效。

不使用 BuildKit 編譯

這邊我們直接拿 Go 語言基本範例來測試看看到底省下多少時間,程式碼都可以在這裡找到,底下是範例:

package main

import (
    "net/http"

    "gin/bar"
    "gin/foo"

    "github.com/gin-gonic/gin"
)

func main() {
    r := gin.Default()
    r.GET("/ping", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
            "message": "pong",
        })
    })
    r.GET("/ping2", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
            "message": "pong2",
        })
    })
    r.GET("/ping100", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
            "message": foo.Foo(),
        })
    })
    r.GET("/ping101", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
            "message": bar.Bar(),
        })
    })
    r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}

接著撰寫 Dockerfile

FROM golang:1.14-alpine

LABEL maintainer="Bo-Yi Wu <appleboy.tw@gmail.com>"

RUN apk add bash ca-certificates git gcc g++ libc-dev
WORKDIR /app
# Force the go compiler to use modules
ENV GO111MODULE=on
# We want to populate the module cache based on the go.{mod,sum} files.
COPY go.mod .
COPY go.sum .
RUN go mod download
COPY main.go .
COPY foo/foo.go foo/
COPY bar/bar.go bar/

ENV GOOS=linux
ENV GOARCH=amd64
RUN go build -o /app -v -tags netgo -ldflags '-w -extldflags "-static"' .

CMD ["/app"]

可以看到如果 go.mode 跟 go.sum 如果沒有任何變動,基本上 go module 檔案自然就可以透過 docker cache layer 處理。但是每次只要程式碼有任何異動,最後的 go build 會從無到有編譯,請看底下結果:

docker build --progress=plain -t appleboy/docker-demo -f Dockerfile .
#14 [10/10] RUN go build -o /app -v -tags netgo -ldflags '-w -extldflags "-s...
#14 0.391 gin/foo
#14 0.403 gin/bar
#14 0.412 github.com/go-playground/locales/currency
#14 0.438 github.com/gin-gonic/gin/internal/bytesconv
#14 0.441 github.com/go-playground/locales
#14 0.449 golang.org/x/sys/unix
#14 0.464 net
#14 0.471 github.com/gin-gonic/gin/internal/json
#14 0.508 github.com/go-playground/universal-translator
#14 0.511 github.com/leodido/go-urn
#14 0.694 github.com/golang/protobuf/proto
#14 0.754 gopkg.in/yaml.v2
#14 1.535 github.com/mattn/go-isatty
#14 1.789 net/textproto
#14 1.790 crypto/x509
#14 1.920 vendor/golang.org/x/net/http/httpproxy
#14 1.978 vendor/golang.org/x/net/http/httpguts
#14 2.019 github.com/go-playground/validator/v10
#14 2.434 crypto/tls
#14 3.043 net/http/httptrace
#14 3.085 net/http
#14 4.211 net/rpc
#14 4.212 github.com/gin-contrib/sse
#14 4.212 net/http/httputil
#14 4.372 github.com/ugorji/go/codec
#14 6.322 github.com/gin-gonic/gin/binding
#14 6.322 github.com/gin-gonic/gin/render
#14 6.517 github.com/gin-gonic/gin
#14 6.819 gin
#14 DONE 7.8s

總共花了 7.8 秒,但是各位想想,在自己電腦開發時,不會這麼久,而是會根據修正過的 Go 檔案才會進行編譯,但是在 CI/CD 流程怎麼做到呢?其實可以發現在電腦裡面都有 Cache 過已經編譯過的檔案。在 Linux 環境會是 /root/.cache/go-build。那我們該如何透過 buildKit 加速編譯?

使用 BuildKit 編譯

先來看看在 Dockerfile 該如何改進才可以讓編譯加速?底下看看

# syntax = docker/dockerfile:experimental
FROM golang:1.14-alpine

LABEL maintainer="Bo-Yi Wu <appleboy.tw@gmail.com>"

RUN --mount=type=cache,target=/var/cache/apk apk add bash ca-certificates git gcc g++ libc-dev
WORKDIR /app
# Force the go compiler to use modules
ENV GO111MODULE=on
# We want to populate the module cache based on the go.{mod,sum} files.
COPY go.mod .
COPY go.sum .
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY main.go .
COPY foo/foo.go foo/
COPY bar/bar.go bar/

ENV GOOS=linux
ENV GOARCH=amd64
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go build -o /app -v -tags netgo -ldflags '-w -extldflags "-static"' .

CMD ["/app"]

首先看到第一行是務必要填寫

# syntax = docker/dockerfile:experimental

接著使用 --mount 方式進行檔案 cache,可以在任何 RUN 的步驟進行。所以可以看到在 go build 地方使用了:

RUN --mount=type=cache,target=/go/pkg/mod \
  --mount=type=cache,target=/root/.cache/go-build

可以看到此步驟將 go module 及 build 後的檔案全部 cache 下來,這樣下次編譯的時候,就會自動將檔案預設放在對應的位置,加速編譯流程

docker build --progress=plain -t appleboy/docker-buildkit -f Dockerfile.buildkit .
#16 [stage-0 10/10] RUN --mount=type=cache,target=/go/pkg/mod --mount=type=c...
#16 0.381 gin/foo
#16 0.447 gin
#16 DONE 1.2s

可以看到修改了檔案後,編譯的結果跟在自己電腦上一模一樣,縮短了六秒時間,在大型的 Go 專案省下的時間可不少啊。

心得

現在 CI/CD 的工具不確定都有支持 docker buildKit,可能要自己做實驗試試看,像是現在 GitHub Action 官方也不支援 docker buildkit。如果是全部自己架設的話,基本上可以完全使用 docker buildKit + docker cache-from 兩者一起用,相信會省下不少時間啊。

程式碼範例請看這邊

用 10 分鐘了解 Go 語言 context package 使用場景及介紹

$
0
0

golang logo

context 是在 Go 語言 1.7 版才正式被納入官方標準庫內,為什麼今天要介紹 context 使用方式呢?原因很簡單,在初學 Go 時,寫 API 時,常常不時就會看到在 http handler 的第一個參數就會是 ctx context.Context,而這個 context 在這邊使用的目的及含義到底是什麼呢,本篇就是帶大家了解什麼是 context,以及使用的場景及方式,內容不會提到 context 的原始碼,而是用幾個實際例子來了解。

教學影片

如果對於課程內容有興趣,可以參考底下課程。

如果需要搭配購買請直接透過 FB 聯絡我,直接匯款(價格再減 100

使用 WaitGroup

學 Go 時肯定要學習如何使用併發 (goroutine),而開發者該如何控制併發呢?其實有兩種方式,一種是 WaitGroup,另一種就是 context,而什麼時候需要用到 WaitGroup 呢?很簡單,就是當您需要將同一件事情拆成不同的 Job 下去執行,最後需要等到全部的 Job 都執行完畢才繼續執行主程式,這時候就需要用到 WaitGroup,看個實際例子

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var wg sync.WaitGroup

    wg.Add(2)
    go func() {
        time.Sleep(2 * time.Second)
        fmt.Println("job 1 done.")
        wg.Done()
    }()
    go func() {
        time.Sleep(1 * time.Second)
        fmt.Println("job 2 done.")
        wg.Done()
    }()
    wg.Wait()
    fmt.Println("All Done.")
}

上面範例可以看到主程式透過 wg.Wait() 來等待全部 job 都執行完畢,才印出最後的訊息。這邊會遇到一個情境就是,雖然把 job 拆成多個,並且丟到背景去跑,可是使用者該如何透過其他方式來終止相關 goroutine 工作呢 (像是開發者都會寫背景程式監控,需要長時間執行)?例如 UI 上面有停止的按鈕,點下去後,如何主動通知並且停止正在跑的 Job,這邊很簡單,可以使用 channel + select 方式。

使用 channel + select

package main

import (
    "fmt"
    "time"
)

func main() {
    stop := make(chan bool)

    go func() {
        for {
            select {
            case <-stop:
                fmt.Println("got the stop channel")
                return
            default:
                fmt.Println("still working")
                time.Sleep(1 * time.Second)
            }
        }
    }()

    time.Sleep(5 * time.Second)
    fmt.Println("stop the gorutine")
    stop <- true
    time.Sleep(5 * time.Second)
}

上面可以看到,透過 select + channel 可以快速解決這問題,只要在任何地方將 bool 值丟入 stop channel 就可以停止背景正在處理的 Job。上述用 channel 來解決此問題,但是現在有個問題,假設背景有跑了無數個 goroutine,或者是 goroutine 內又有跑 goroutine 呢,變得相當複雜,例如底下的狀況

cancel

這邊就沒辦法用 channel 方式來進行處理了,而需要用到今天的重點 context。

認識 context

從上圖可以看到我們建立了三個 worker node 來處理不同的 Job,所以會在主程式最上面宣告一個主 context.Background(),然後在每個 worker node 分別在個別建立子 context,其最主要目的就是當關閉其中一個 context 就可以直接取消該 worker 內正在跑的 Job。拿上面的例子進行改寫

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    go func() {
        for {
            select {
            case <-ctx.Done():
                fmt.Println("got the stop channel")
                return
            default:
                fmt.Println("still working")
                time.Sleep(1 * time.Second)
            }
        }
    }()

    time.Sleep(5 * time.Second)
    fmt.Println("stop the gorutine")
    cancel()
    time.Sleep(5 * time.Second)
}

其實可以看到只是把原本的 channel 換成使用 context 來處理,其他完全不變,這邊提到使用了 context.WithCancel,使用底下方式可以擴充 context

ctx, cancel := context.WithCancel(context.Background())

這用意在於每個 worknode 都有獨立的 cancel func 開發者可以透過其他地方呼叫 cancel() 來決定哪一個 worker 需要被停止,這時候可以做到使用 context 來停止多個 goroutine 的效果,底下看看實際例子

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    go worker(ctx, "node01")
    go worker(ctx, "node02")
    go worker(ctx, "node03")

    time.Sleep(5 * time.Second)
    fmt.Println("stop the gorutine")
    cancel()
    time.Sleep(5 * time.Second)
}

func worker(ctx context.Context, name string) {
    go func() {
        for {
            select {
            case <-ctx.Done():
                fmt.Println(name, "got the stop channel")
                return
            default:
                fmt.Println(name, "still working")
                time.Sleep(1 * time.Second)
            }
        }
    }()
}

上面透過一個 context 可以一次停止多個 worker,看邏輯如何宣告 context 以及什麼時機去執行 cancel(),通常我個人都是搭配 graceful shutdown 進行取消正在跑的 Job,或者是停止資料庫連線等等..

心得

初學 Go 時,如果還不常使用 goroutine,其實也不會理解到 context 的使用方式及時機,等到需要有背景處理,以及該如何停止 Job,這時候才漸漸瞭解到使用方式,當然 context 不只有這個使用方式,未來還會介紹其他使用方式。

Go 語言效能檢查工具 pprof

$
0
0

golang logo

Go 語言除了內建強大的測試工具 (go test) 之外,也提供了效能評估的工具 (go tool pprof),整個生態鏈非常完整,這也是我推薦大家使用 Go 語言的最大原因,這篇會介紹如何使用 pprof 來找出效能瓶頸的地方。假設開發者在寫任何邏輯功能時,發現跑出來的速度不是想像的這麼快,或者是在串接服務流程時,整個回覆時間特別久,這時候可以透過 benchmark 先找出原因。

go test -bench=. -benchtime=3s ./lexer/

可以看到底下輸出結果

BenchmarkCreateKeyString1-8   100000000   35.9 ns/op   8 B/op  1 allocs/op
BenchmarkCreateKeyString2-8   85222555    42.4 ns/op   8 B/op  1 allocs/op
BenchmarkCreateKeyString3-8   73403774    48.0 ns/op   8 B/op  1 allocs/op

從上面數據可以看到效能結果,開發者可以根據這結果來調教程式碼,改善過後再透過一樣的指令來評估是否有改善成功。我個人通常開一個新的 performance 分支來進行效能調校,調教完成後,再執行上面指令輸出到存文字檔

go test -bench=. -benchtime=3s ./lexer/ > new.txt

接著切回去 master 分支,用同樣的指令

go test -bench=. -benchtime=3s ./lexer/ > old.txt

接著用 benchstat 來看看是否有改善,改善了多少?

$ benchstat -alpha 3 a.txt b.txt
name     old time/op    new time/op    delta
Lexer-8    3.43µs ± 0%    2.22µs ± 0%  -35.23%  (p=1.000 n=1+1)

name     old speed      new speed      delta
Lexer-8   242MB/s ± 0%   373MB/s ± 0%  +54.36%  (p=1.000 n=1+1)

name     old alloc/op   new alloc/op   delta
Lexer-8      896B ± 0%      896B ± 0%     ~     (all equal)

name     old allocs/op  new allocs/op  delta
Lexer-8      1.00 ± 0%      1.00 ± 0%     ~     (all equal)

效能評估

上面方式來評估效能之外,最主要遇到的問題會是,在一大段程式碼及邏輯中,要找出慢的主因,就不能光是靠上面的方式,因為開發者不會知道整段程式碼到底慢在哪邊,100 行內要找出慢的原因很難,那 1000 行更難,所以需要透過其他方式來處理。這時候就要使用到 pprof 來找出程式碼所有執行的時間,怎麼輸出 CPU 所花的時間,可以透過底下指令:

go test -bench=. -benchtime=3s \
  -cpuprofile cpu.out \
  ./lexer/

產生出 cpu.out 後,就可以使用 go 指令來看看哪邊出問題

go tool pprof cpu.out

可以進到 console 畫面:

$ go tool pprof cpu.out 
Type: cpu
Time: Jun 7, 2020 at 11:26am (CST)
Duration: 6.04s, Total samples = 5.95s (98.56%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)

接下來使用方式非常簡單,可以使用 top 功能來看數據

(pprof) top10
Showing nodes accounting for 4850ms, 81.51% of 5950ms total
Dropped 66 nodes (cum <= 29.75ms)
Showing top 10 nodes out of 81
      flat  flat%   sum%        cum   cum%
    1130ms 18.99% 18.99%     3600ms 60.50%  LibertyParser/lexer.(*Lexer).NextToken
     970ms 16.30% 35.29%      970ms 16.30%  LibertyParser/lexer.(*Lexer).readChar
     770ms 12.94% 48.24%      770ms 12.94%  runtime.kevent
     730ms 12.27% 60.50%      730ms 12.27%  LibertyParser/lexer.isLetter (inline)
     310ms  5.21% 65.71%     1480ms 24.87%  LibertyParser/lexer.(*Lexer).readIdentifier
     290ms  4.87% 70.59%      290ms  4.87%  runtime.madvise
     210ms  3.53% 74.12%      210ms  3.53%  runtime.pthread_cond_wait
     170ms  2.86% 76.97%      170ms  2.86%  runtime.memclrNoHeapPointers
     140ms  2.35% 79.33%     4200ms 70.59%  LibertyParser/lexer.BenchmarkLexer
     130ms  2.18% 81.51%      370ms  6.22%  LibertyParser/lexer.(*Lexer).readString

注意 flat 代表執行該 func 所需要的時間 (不包含內部其他 func 所需要的時間),而 cum 則是包含全部函示的執行時間。接下來就可以看到整個列表需要改善的項目,像是要改善 readChar 就可以直接執行 list readChar

(pprof) list readChar
Total: 5.95s
ROUTINE ======================== LibertyParser/lexer.(*Lexer).readChar in /Users/appleboy/git/appleboy/LibertyParser/lexer/lexer.go
     970ms      970ms (flat, cum) 16.30% of Total
         .          .     22:   l.readChar()
         .          .     23:   return l
         .          .     24:}
         .          .     25:
         .          .     26:func (l *Lexer) readChar() {
     260ms      260ms     27:   if l.readPosition >= len(l.Data) {
         .          .     28:           // End of input (haven't read anything yet or EOF)
         .          .     29:           // 0 is ASCII code for "NUL" character
         .          .     30:           l.char = 0
         .          .     31:   } else {
     620ms      620ms     32:           l.char = l.Data[l.readPosition]
         .          .     33:   }
         .          .     34:
      50ms       50ms     35:   l.position = l.readPosition
      40ms       40ms     36:   l.readPosition++
         .          .     37:}
         .          .     38:

開發者可以清楚看到每一行所需要的執行時間 (flat, cum),這樣就可以知道時間到底慢在哪邊?哪邊需要進行關鍵性優化。沒有這些數據,開發者就只能自己使用傳統方式 log.Println() 方式來進行除錯。除了上述這些之外,pprof 也提供其他方式來觀看,像是輸出 pdf 之類的,只要在 console 內鍵入 pdf 即可,pdf 內容會有更詳細的圖

pprof

除了透過在 console 端操作之外,開發者也可以透過 web 方式來進行 UI 操作,對比 console 來說,看到完整的 pprof 報表,這樣更方便除錯。

go tool pprof -http=:8080 cpu.out

自動會開啟 web 顯示,個人覺得相當的方便,從 console 操作轉到 UI 操作,體驗上還是有差別的。

心得

善用 pprof 可以改善蠻多效能上的問題,也可以抓到哪邊的邏輯寫錯,造成跑太多次,導致效能變差,除了寫法上差異之外,最主要還有程式上的邏輯,也許換個方式效能就改善很多。本篇算是 pprof 的初探,希望大家會喜歡。

Go 1.5 新增 Module cache 環境變數

$
0
0

golang logo

相信各位開發者在寫 Go 語言專案,現在肯定都是使用 Go module 了,而 Go Module 檔案預設寫在 /go/pkg/mod 目錄內,要串 CI/CD 流程時,由於不在專案路徑底下,所以每一個 Container 無法共用 /go/pkg/mod 路徑,造成重複下載第三方套件,其實跨容器的解決方式可以透過 DroneTemporary Volumes 方式解決,但是最終希望跑完編譯流程時,可以將最後的 mod 目錄打包留到下次的 CI/CD 部署流程使用,這時候如果可以改變 /go/pkg/mod 路徑,就可以動態調整目錄結構了。底下是針對 Drone 這套部署工具進行解說。

教學影片

如果對於課程內容有興趣,可以參考底下課程。

如果需要搭配購買請直接透過 FB 聯絡我,直接匯款(價格再減 100

GOMODCACHE 環境變數

Go 1.15 開始支援 GOMODCACHE 環境變數,此變數預設是 GOPATH[0]/pkg/mod,現在這個路徑可以透過環境變數進行修正了。本篇教學會使用 meltwater/drone-cache 套件來完成 go module 的 cache 機制,加快後續每次的 CI/CD 部署。要進行 cache 前,我們需要在 pipeline 先建立 Temporary Volumes

volumes:
- name: cache
  temp: {}

有了這個暫時性的空間,就可以在不同步驟的容器內看到相同的檔案了。接著設定編譯 Go 專案的步驟

- name: build
  pull: always
  image: golang:1.15-rc
  commands:
  - make build
  environment:
    CGO_ENABLED: 0
    GOMODCACHE: '/drone/src/pkg.mod'
    GOCACHE: '/drone/src/pkg.build'
  when:
    event:
      exclude:
      - tag
  volumes:
  - name: cache
    path: /go

這邊可以注意,由於 Go 1.15 尚未釋出 (預計 2020/08),所以先用了 rc 版本。這邊可以注意我們修改了 GOMODCACHE,將原本 mod 內容放到 /drone/src/pkg.mod/drone/src 就是專案目錄了,所以記得設定一個不會用到的目錄名稱,請使用絕對路徑。

使用 build cache

在 CI/CD 編譯流程,第一個步驟就是將遠端備份好的 mod 檔案下載到容器內,並且解壓縮到 GOMODCACHE 所指定的路徑。

- name: restore-cache
  image: meltwater/drone-cache
  environment:
    AWS_ACCESS_KEY_ID:
      from_secret: aws_access_key_id
    AWS_SECRET_ACCESS_KEY:
      from_secret: aws_secret_access_key
  pull: always
  settings:
    debug: true
    restore: true
    cache_key: '{{ .Repo.Name }}_{{ checksum "go.mod" }}_{{ checksum "go.sum" }}_{{ arch }}_{{ os }}'
    bucket: drone-cache-demo
    region: ap-northeast-1
    local_root: /
    archive_format: gzip
    mount:
      - pkg.mod
      - pkg.build
  volumes:
  - name: cache
    path: /go

這邊可以看到我是使用了 AWS S3 做為背後的 Storage,你也可以透過 SFTP 或其他方式來做變化。在 archive_format 請選擇 gzip,可以讓檔案更小些。接著會進行一系列 Go 的流程,像是測試,編譯,打包 … 等等,最後會將 pkg.mod 進行打包再上傳到 AWS S3。

- name: rebuild-cache
  image: meltwater/drone-cache
  pull: always
  environment:
    AWS_ACCESS_KEY_ID:
      from_secret: aws_access_key_id
    AWS_SECRET_ACCESS_KEY:
      from_secret: aws_secret_access_key
  settings:
    rebuild: true
    cache_key: '{{ .Repo.Name }}_{{ checksum "go.mod" }}_{{ checksum "go.sum" }}_{{ arch }}_{{ os }}'
    bucket: drone-cache-demo
    region: ap-northeast-1
    archive_format: gzip
    mount:
      - pkg.mod
      - pkg.build
  volumes:
  - name: cache
    path: /go

程式碼請參考這邊

Viewing all 325 articles
Browse latest View live