golang etcd 簡明教程

KevinYan發表於2019-11-29

etcd 是一個高可用強一致性的鍵值倉庫在很多分散式系統架構中得到了廣泛的應用,本教程結合一些簡單的例子介紹golang版本的etcd/clientv3中提供的主要功能及其使用方法。

如果還不熟悉etcd推薦先閱讀:

看圖輕鬆瞭解etcd

etcd常用操作介紹

Let's get started now!

安裝package

我們使用v3版本的etcd client, 首先透過go get下載並編譯安裝etcd clinet v3

go get github.com/coreos/etcd/clientv3

該命令會將包下載到$GOPATH/src/github.com/coreos/etcd/clientv3中,所有相關依賴包會自動下載編譯,包括protobufgrpc等。

官方文件地址:https://godoc.org/github.com/coreos/etcd/c...

文件中列出了Go官方實現的etcd client中支援的所有方法,方法還是很多的,我們主要梳理一下使用etcd時經常用到的主要API並進行演示。

連線客戶端

用程式訪問etcd首先要建立client,它需要傳入一個Config配置,這裡傳了2個選項:

  • Endpoints:etcd的多個節點服務地址。
  • DialTimeout:建立client的首次連線超時時間,這裡傳了5秒,如果5秒都沒有連線成功就會返回err;一旦client建立成功,我們就不用再關心後續底層連線的狀態了,client內部會重連。
cli, err := clientv3.New(clientv3.Config{
   Endpoints:   []string{"localhost:2379"},
   // Endpoints: []string{"localhost:2379", "localhost:22379", "localhost:32379"}
   DialTimeout: 5 * time.Second,
})

返回的client,它的型別具體如下:

type Client struct {
    Cluster
    KV
    Lease
    Watcher
    Auth
    Maintenance
    // Username is a user name for authentication.
    Username string
    // Password is a password for authentication.
    Password string
    // contains filtered or unexported fields
}

型別中的成員是etcd客戶端幾何核心功能模組的具體實現,它們分別用於:

  • Cluster:向叢集裡增加etcd服務端節點之類,屬於管理員操作。
  • KV:我們主要使用的功能,即K-V鍵值庫的操作。
  • Lease:租約相關操作,比如申請一個TTL=10秒的租約(應用給key可以實現鍵值的自動過期)。
  • Watcher:觀察訂閱,從而監聽最新的資料變化。
  • Auth:管理etcd的使用者和許可權,屬於管理員操作。
  • Maintenance:維護etcd,比如主動遷移etcd的leader節點,屬於管理員操作。

我們需要使用什麼功能,就去client裡獲取對應的成員即可。

Client.KV是一個interface`,提供了關於K-V操作的所有方法:

type KV interface {

    Put(ctx context.Context, key, val string, opts ...OpOption) (*PutResponse, error)

    Get(ctx context.Context, key string, opts ...OpOption) (*GetResponse, error)

    // Delete deletes a key, or optionally using WithRange(end), [key, end).
    Delete(ctx context.Context, key string, opts ...OpOption) (*DeleteResponse, error)

    // Compact compacts etcd KV history before the given rev.
    Compact(ctx context.Context, rev int64, opts ...CompactOption) (*CompactResponse, error)

    Do(ctx context.Context, op Op) (OpResponse, error)

    // Txn creates a transaction.
    Txn(ctx context.Context) Txn
}

我們透過方法clientv3.NewKV()來獲得KV介面的實現(實現中內建了錯誤重試機制):

kv := clientv3.NewKV(cli)

接下來,我們將透過kv操作etcd中的資料。

Put

putResp, err := kv.Put(context.TODO(),"/test/key1", "Hello etcd!")

第一個引數是goroutine的上下文Context。後面兩個引數分別是key和value,對於etcd來說,key=/test/key1只是一個字串而已,但是對我們而言卻可以模擬出目錄層級關係。

Put函式的宣告如下:

// Put puts a key-value pair into etcd.
// Note that key,value can be plain bytes array and string is
// an immutable representation of that bytes array.
// To get a string of bytes, do string([]byte{0x10, 0x20}).
Put(ctx context.Context, key, val string, opts ...OpOption) (*PutResponse, error)

除了上面例子中的三個的引數,還支援一個變長引數,可以傳遞一些控制項來影響Put的行為,例如可以攜帶一個lease ID來支援key過期。

Put操作返回的是PutResponse,不同的KV操作對應不同的response結構,所有KV操作返回的response結構如下:

type (
   CompactResponse pb.CompactionResponse
   PutResponse     pb.PutResponse
   GetResponse     pb.RangeResponse
   DeleteResponse  pb.DeleteRangeResponse
   TxnResponse     pb.TxnResponse
)

程式程式碼裡匯入clientv3後在GoLand中可以很快定位到PutResponse的定義檔案中,PutResponse只是pb.PutResponse的型別別名,透過Goland跳轉過去後可以看到PutResponse的詳細定義。

type PutResponse struct {
   Header *ResponseHeader `protobuf:"bytes,1,opt,name=header" json:"header,omitempty"`
   // if prev_kv is set in the request, the previous key-value pair will be returned.
   PrevKv *mvccpb.KeyValue `protobuf:"bytes,2,opt,name=prev_kv,json=prevKv" json:"prev_kv,omitempty"`
}

Header裡儲存的主要是本次更新的revision資訊,而PrevKv可以返回Put覆蓋之前的value是什麼(目前是nil,後面會說原因),把返回的PutResponse列印出來看一下:

fmt.Printf("PutResponse: %v, err: %v", putResp, err)

// output
// PutResponse: &{cluster_id:14841639068965178418 member_id:10276657743932975437 revision:3 raft_term:7  <nil>}, err: <nil>%

我們需要判斷err來確定操作是否成功。

我們再Put其他2個key,用於後續演示:

kv.Put(context.TODO(),"/test/key2", "Hello World!")
// 再寫一個同字首的干擾項
kv.Put(context.TODO(), "/testspam", "spam")

現在/test目錄下有兩個鍵: key1和key2, 而/testspam並不歸屬於/test目錄

Get

使用KV的Get方法來讀取給定鍵的值:

getResp, err := kv.Get(context.TODO(), "/test/key1")

其函式宣告如下:

// Get retrieves keys.
// By default, Get will return the value for "key", if any.
// When passed WithRange(end), Get will return the keys in the range [key, end).
// When passed WithFromKey(), Get returns keys greater than or equal to key.
// When passed WithRev(rev) with rev > 0, Get retrieves keys at the given revision;
// if the required revision is compacted, the request will fail with ErrCompacted .
// When passed WithLimit(limit), the number of returned keys is bounded by limit.
// When passed WithSort(), the keys will be sorted.
Get(ctx context.Context, key string, opts ...OpOption) (*GetResponse, error)

和Put類似,函式註釋裡提示我們可以傳遞一些控制引數來影響Get的行為,比如:WithFromKey表示讀取從引數key開始遞增的所有key,而不是讀取單個key。

在上面的例子中,我沒有傳遞opOption,所以就是獲取key=/test/key1的最新版本資料。

這裡err並不能反饋出key是否存在(只能反饋出本次操作因為各種原因異常了),我們需要透過GetResponse(實際上是pb.RangeResponse)判斷key是否存在:

type RangeResponse struct {
    Header *ResponseHeader `protobuf:"bytes,1,opt,name=header" json:"header,omitempty"`
    // kvs is the list of key-value pairs matched by the range request.
    // kvs is empty when count is requested.
    Kvs []*mvccpb.KeyValue `protobuf:"bytes,2,rep,name=kvs" json:"kvs,omitempty"`
    // more indicates if there are more keys to return in the requested range.
    More bool `protobuf:"varint,3,opt,name=more,proto3" json:"more,omitempty"`
    // count is set to the number of keys within the range when requested.
    Count int64 `protobuf:"varint,4,opt,name=count,proto3" json:"count,omitempty"`
}

Kvs欄位,儲存了本次Get查詢到的所有k-v對,因為上述例子只Get了一個單key,所以只需要判斷一下len(Kvs)是否等於1即可知道key是否存在。

RangeResponse.MoreCount,當我們使用withLimit()等選項進行Get時會發揮作用,相當於翻頁查詢。

接下來,我們透過給Get查詢增加WithPrefix選項,獲取/test目錄下的所有子元素:

rangeResp, err := kv.Get(context.TODO(), "/test/", clientv3.WithPrefix())

WithPrefix()是指查詢以/test/為字首的所有key,因此可以模擬出查詢子目錄的效果。

etcd是一個有序的k-v儲存,因此/test/為字首的key總是順序排列在一起。

withPrefix()實際上會轉化為範圍查詢,它根據字首/test/生成了一個前閉後開的key range:[“/test/”, “/test0”),為什麼呢?因為比/大的字元是0,所以以/test0作為範圍的末尾,就可以掃描到所有以/test/為字首的key了。

在之前,我們Put了一個/testspam鍵值,因為不符合/test/字首(注意末尾的/),所以就不會被這次Get獲取到。但是,如果查詢的字首是/test,那麼/testspam就會被返回,使用時一定要特別注意。

列印rangeResp.Kvs可以看到獲得了兩個鍵值:

[key:"/test/key1" create_revision:2 mod_revision:13 version:6 value:"Hello etcd!"  key:"/test/key2" create_revision:5 mod_revision:14 version:4 value:"Hello World!" ]

Lease

etcd客戶端的Lease物件可以透過以下的程式碼獲取到

lease := clientv3.NewLease(cli)

lease物件是Lease介面的實現,Lease介面的宣告如下:

type Lease interface {
    // Grant 建立一個新租約
    Grant(ctx context.Context, ttl int64) (*LeaseGrantResponse, error)

    // Revoke 銷燬給定租約ID的租約
    Revoke(ctx context.Context, id LeaseID) (*LeaseRevokeResponse, error)

    // TimeToLive retrieves the lease information of the given lease ID.
    TimeToLive(ctx context.Context, id LeaseID, opts ...LeaseOption) (*LeaseTimeToLiveResponse, error)

    // Leases retrieves all leases.
    Leases(ctx context.Context) (*LeaseLeasesResponse, error)

    // KeepAlive keeps the given lease alive forever.
    KeepAlive(ctx context.Context, id LeaseID) (<-chan *LeaseKeepAliveResponse, error)

    // KeepAliveOnce renews the lease once. In most of the cases, KeepAlive
    // should be used instead of KeepAliveOnce.
    KeepAliveOnce(ctx context.Context, id LeaseID) (*LeaseKeepAliveResponse, error)

    // Close releases all resources Lease keeps for efficient communication
    // with the etcd server.
    Close() error
}

Lease提供了以下功能:

  • Grant:分配一個租約。
  • Revoke:釋放一個租約。
  • TimeToLive:獲取剩餘TTL時間。
  • Leases:列舉所有etcd中的租約。
  • KeepAlive:自動定時的續約某個租約。
  • KeepAliveOnce:為某個租約續約一次。
  • Close:釋放當前客戶端建立的所有租約。

要想實現key自動過期,首先得建立一個租約,下面的程式碼建立一個TTL為10秒的租約:

grantResp, err := lease.Grant(context.TODO(), 10)

返回的grantResponse的結構體宣告如下:

// LeaseGrantResponse wraps the protobuf message LeaseGrantResponse.
type LeaseGrantResponse struct {
    *pb.ResponseHeader
    ID    LeaseID
    TTL   int64
    Error string
}

在應用程式程式碼中主要使用到的是租約ID。

接下來我們用這個Lease往etcd中儲存一個10秒過期的key:

kv.Put(context.TODO(), "/test/vanish", "vanish in 10s", clientv3.WithLease(grantResp.ID))

這裡特別需要注意,有一種情況是在Put之前Lease已經過期了,那麼這個Put操作會返回error,此時你需要重新分配Lease。

當我們實現服務註冊時,需要主動給Lease進行續約,通常是以小於TTL的間隔迴圈呼叫Lease的KeepAliveOnce()方法對租約進行續期,一旦某個服務節點出錯無法完成租約的續期,等key過期後客戶端即無法在查詢服務時獲得對應節點的服務,這樣就透過租約到期實現了服務的錯誤隔離。

keepResp, err := lease.KeepAliveOnce(context.TODO(), grantResp.ID)

或者使用KeepAlive()方法,其會返回<-chan *LeaseKeepAliveResponse只讀通道,每次自動續租成功後會向通道中傳送訊號。一般都用KeepAlive()方法

KeepAlive和Put一樣,如果在執行之前Lease就已經過期了,那麼需要重新分配Lease。etcd並沒有提供API來實現原子的Put with Lease,需要我們自己判斷err重新分配Lease。

Op

Op字面意思就是”操作”,Get和Put都屬於Op,只是為了簡化使用者開發而開放的特殊API。

KV物件有一個Do方法接受一個Op:

// Do applies a single Op on KV without a transaction.
// Do is useful when creating arbitrary operations to be issued at a
// later time; the user can range over the operations, calling Do to
// execute them. Get/Put/Delete, on the other hand, are best suited
// for when the operation should be issued at the time of declaration.
Do(ctx context.Context, op Op) (OpResponse, error)

其引數Op是一個抽象的操作,可以是Put/Get/Delete…;而OpResponse是一個抽象的結果,可以是PutResponse/GetResponse…

可以透過Client中定義的一些方法來建立Op:

  • func OpDelete(key string, opts …OpOption) Op
  • func OpGet(key string, opts …OpOption) Op
  • func OpPut(key, val string, opts …OpOption) Op
  • func OpTxn(cmps []Cmp, thenOps []Op, elseOps []Op) Op

其實和直接呼叫KV.Put,KV.GET沒什麼區別。

下面是一個例子:

cli, err := clientv3.New(clientv3.Config{
    Endpoints:   endpoints,
    DialTimeout: dialTimeout,
})
if err != nil {
    log.Fatal(err)
}
defer cli.Close()

ops := []clientv3.Op{
    clientv3.OpPut("put-key", "123"),
    clientv3.OpGet("put-key"),
    clientv3.OpPut("put-key", "456")}

for _, op := range ops {
    if _, err := cli.Do(context.TODO(), op); err != nil {
        log.Fatal(err)
    }
}

把Op交給Do方法執行,返回的opResp結構如下:

type OpResponse struct {
    put *PutResponse
    get *GetResponse
    del *DeleteResponse
    txn *TxnResponse
}

你的操作是什麼型別,你就用哪個指標來訪問對應的結果。

Txn事務

etcd中事務是原子執行的,只支援if … then … else …這種表達。首先來看一下Txn中定義的方法:

type Txn interface {
    // If takes a list of comparison. If all comparisons passed in succeed,
    // the operations passed into Then() will be executed. Or the operations
    // passed into Else() will be executed.
    If(cs ...Cmp) Txn

    // Then takes a list of operations. The Ops list will be executed, if the
    // comparisons passed in If() succeed.
    Then(ops ...Op) Txn

    // Else takes a list of operations. The Ops list will be executed, if the
    // comparisons passed in If() fail.
    Else(ops ...Op) Txn

    // Commit tries to commit the transaction.
    Commit() (*TxnResponse, error)
}

Txn必須是這樣使用的:If(滿足條件) Then(執行若干Op) Else(執行若干Op)。

If中支援傳入多個Cmp比較條件,如果所有條件滿足,則執行Then中的Op(上一節介紹過Op),否則執行Else中的Op。

首先,我們需要開啟一個事務,這是透過KV物件的方法實現的:

txn := kv.Txn(context.TODO())

下面的測試程式,判斷如果k1的值大於v1並且k1的版本號是2,則Put 鍵值k2和k3,否則Put鍵值k4和k5。

kv.Txn(context.TODO()).If(
 clientv3.Compare(clientv3.Value(k1), ">", v1),
 clientv3.Compare(clientv3.Version(k1), "=", 2)
).Then(
 clientv3.OpPut(k2,v2), clentv3.OpPut(k3,v3)
).Else(
 clientv3.OpPut(k4,v4), clientv3.OpPut(k5,v5)
).Commit()

類似於clientv3.Value()\用於指定key屬性的,有這麼幾個方法:

  • func CreateRevision(key string) Cmp:key=xxx的建立版本必須滿足…
  • func LeaseValue(key string) Cmp:key=xxx的Lease ID必須滿足…
  • func ModRevision(key string) Cmp:key=xxx的最後修改版本必須滿足…
  • func Value(key string) Cmp:key=xxx的建立值必須滿足…
  • func Version(key string) Cmp:key=xxx的累計更新次數必須滿足…

Watch

Watch用於監聽某個鍵的變化, Watch呼叫後返回一個WatchChan,它的型別宣告如下:

type WatchChan <-chan WatchResponse

type WatchResponse struct {
    Header pb.ResponseHeader
    Events []*Event

    CompactRevision int64

    Canceled bool

    Created bool
}

當監聽的key有變化後會向WatchChan傳送WatchResponse。Watch的典型應用場景是應用於系統配置的熱載入,我們可以在系統讀取到儲存在etcd key中的配置後,用Watch監聽key的變化。在單獨的goroutine中接收WatchChan傳送過來的資料,並將更新應用到系統設定的配置變數中,比如像下面這樣在goroutine中更新變數appConfig,這樣系統就實現了配置變數的熱載入。

type AppConfig struct {
  config1 string
  config2 string
}

var appConfig Appconfig

func watchConfig(clt *clientv3.Client, key string, ss interface{}) {
    watchCh := clt.Watch(context.TODO(), key)
    go func() {
        for res := range watchCh {
            value := res.Events[0].Kv.Value
            if err := json.Unmarshal(value, ss); err != nil {
                fmt.Println("now", time.Now(), "watchConfig err", err)
                continue
            }
            fmt.Println("now", time.Now(), "watchConfig", ss)
        }
    }()
}

watchConfig(client, "config_key", &appConfig)

golang etcd clientv3的主要功能就是這些,希望能幫大家梳理出學習脈絡,這樣工作中應用到etcd時再看官方文件就會容易很多。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
公眾號:網管叨bi叨 | Golang、Laravel、Docker、K8s等學習經驗分享

相關文章