go-zero微服務實戰系列(四、CRUD熱熱身)

語言: CN / TW / HK

上一篇文章我們把整個專案的架子搭建完成,服務在本地也已經能執行起來了,順利成章的接下來我們就應該開始寫業務邏輯程式碼了,但是單純的寫業務邏輯程式碼是比較枯燥的,業務邏輯的程式碼我會不斷地補充到 lerbon 專案中去,關鍵部分我也會加上註釋。

那麼本篇文章我主要想和大家分享下服務的基本配置和幾個典型的程式碼示例。

日誌定義

go-zero的 logx 包提供了日誌功能,預設不需要做任何配置就可以在stdout中輸出日誌。當我們請求/v1/order/list介面的時候輸出日誌如下,預設是json格式輸出,包括時間戳,http請求的基本資訊,介面耗時,以及鏈路追蹤的span和trace資訊。

{"@timestamp":"2022-06-11T08:23:36.342+08:00","caller":"handler/loghandler.go:197","content":"[HTTP] 200 - GET /v1/order/list?uid=123 - 127.0.0.1:59998 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36","duration":"21.2ms","level":"info","span":"23c4deaa3432fd03","trace":"091ffcb0eafe7818b294e4d8122cf8a1"}

程式啟動後,框架會預設輸出level為stat的統計日誌,用於輸出當前資源的使用情況,主要為cpu和記憶體,內容如下:

{"@timestamp":"2022-06-11T08:34:58.402+08:00","caller":"stat/usage.go:61","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=7.0Mi, Sys=16.3Mi, NumGC=8","level":"stat"}

當我們不需要這類日誌的時候,我們可以通過如下方式關閉該類日誌的輸出:

logx.DisableStat()

有的時候我們只需要記錄錯誤日誌,可以通過設定日誌等級來取消level為info級別日誌的輸出:

logx.SetLevel(logx.ErrorLevel)

可以擴充套件日誌輸出的欄位,添加了uid欄位記錄請求的使用者的uid,日誌列印內容如下:

logx.Infow("order list", logx.Field("uid",req.UID))
{"@timestamp":"2022-06-11T08:53:50.609+08:00","caller":"logic/orderlistlogic.go:31","content":"order list","level":"info","uid":123}

我們還可以擴充套件其他第三方日誌庫,通過logx.SetWriter來進行設定

writer := logrusx.NewLogrusWriter(func(logger *logrus.Logger) {
    logger.SetFormatter(&logrus.JSONFormatter{})
})
logx.SetWriter(writer)

同時logx還提供了豐富的配置,可以配置日誌輸出模式,時間格式,輸出路徑,是否壓縮,日誌儲存時間等

type LogConf struct {
    ServiceName         string `json:",optional"`
    Mode                string `json:",default=console,options=[console,file,volume]"`
    Encoding            string `json:",default=json,options=[json,plain]"`
    TimeFormat          string `json:",optional"`
    Path                string `json:",default=logs"`
    Level               string `json:",default=info,options=[info,error,severe]"`
    Compress            bool   `json:",optional"`
    KeepDays            int    `json:",optional"`
    StackCooldownMillis int    `json:",default=100"`
}

可以看到logx提供的日誌功能還是非常豐富的,同時支援了各種自定義的方式。日誌是我們排查線上問題非常重要的依賴,我們還會根據日誌做各種告警,所以這裡我們先做了一些日誌使用的介紹。

服務依賴

在BFF服務中會依賴多個RPC服務,預設情況下,如果依賴的RPC服務沒有啟動,BFF服務也會啟動異常,報錯如下,通過日誌可以知道是因為order.rpc沒有啟動,因為order.rpc是整個商城系統的核心服務,BFF對order.rpc是強依賴,在強依賴的情況下如果被依賴服務異常,那麼依賴服務也無法正常啟動。

{"@timestamp":"2022-06-11T10:21:56.711+08:00","caller":"internal/discovbuilder.go:34","content":"bad resolver state","level":"error"}
2022/06/11 10:21:59 rpc dial: discov://127.0.0.1:2379/order.rpc, error: context deadline exceeded, make sure rpc service "order.rpc" is already started
exit status 1

再看如下的場景,BFF依賴reply.rpc,因為reply.rpc異常導致BFF無法正常啟動,由於reply.rpc並不是商城系統的核心依賴,就算reply.rpc掛掉也不影響商城的核心流程,所以對於BFF來說reply.rpc是弱依賴,在弱依賴的情況下不應該影響依賴方的啟動。

{"@timestamp":"2022-06-11T11:26:51.711+08:00","caller":"internal/discovbuilder.go:34","content":"bad resolver state","level":"error"}
2022/06/11 11:26:54 rpc dial: discov://127.0.0.1:2379/reply.rpc, error: context deadline exceeded, make sure rpc service "reply.rpc" is already started
exit status 1

在go-zero中提供了弱依賴的配置,配置後BFF即可正常啟動,可以看到order.rpc和product.rpc都是強依賴,而reply.rpc配置了NonBlock:true為弱依賴

OrderRPC:
    Etcd:
        Hosts:
          - 127.0.0.1:2379
        Key: order.rpc
ProductRPC:
  Etcd:
    Hosts:
      - 127.0.0.1:2379
    Key: product.rpc
ReplyRPC:
  Etcd:
    Hosts:
      - 127.0.0.1:2379
    Key: reply.rpc
  NonBlock: true

並行呼叫

在高併發的系統中,介面耗時是我們非常關注的點,介面快速響應可以提升使用者體驗,長時間的等待會讓使用者體驗很差,使用者也就會慢慢的離開我們。這裡我們介紹簡單但很實用的提升介面響應時間的方法,那就是並行的依賴呼叫。

下圖展示了序列呼叫和並行呼叫的區別,序列呼叫依賴的話,耗時等於所有依賴耗時的和,並行呼叫依賴的話,耗時等於所有依賴中耗時最大的一個依賴的耗時。

在獲取商品詳情的介面中,引數ProductIds為逗號分隔的多個商品id,在這裡我們使用go-zero提供的mapreduce來並行的根據商品id獲取商品詳情,程式碼如下,詳細程式碼請參考product-rpc服務:

func (l *ProductsLogic) Products(in *product.ProductRequest) (*product.ProductResponse, error) {
    products := make(map[int64]*product.ProductItem)
    pdis := strings.Split(in.ProductIds, ",")
    ps, err := mr.MapReduce(func(source chan<- interface{}) {
        for _, pid := range pdis {
            source <- pid
        }
    }, func(item interface{}, writer mr.Writer, cancel func(error)) {
        pid := item.(int64)
        p, err := l.svcCtx.ProductModel.FindOne(l.ctx, pid)
        if err != nil {
            cancel(err)
            return
        }
        writer.Write(p)
    }, func(pipe <-chan interface{}, writer mr.Writer, cancel func(error)) {
        var r []*model.Product
        for p := range pipe {
            r = append(r, p.(*model.Product))
        }
        writer.Write(r)
    })
    if err != nil {
        return nil, err
    }
    for _, p := range ps.([]*model.Product) {
        products[p.Id] = &product.ProductItem{
            ProductId: p.Id,
            Name:      p.Name,
        }
    }
    return &product.ProductResponse{Products: products}, nil
}

在商品詳情頁,不僅展示了商品的詳情,同時頁展示了商品評價的第一頁,然後點選評價詳情可以跳轉到評價詳情頁,為了避免客戶端同時請求多個介面,所以我們在商品詳情頁把評論首頁的內容一併返回,因為評論內容並不是核心內容所以在這裡我們還做了降級,即請求reply.rpc介面報錯我們會忽略這個錯誤,從而能讓商品詳情正常的展示。因為獲取商品詳情和商品評價沒有前後依賴關係,所以這裡我們使用mr.Finish來並行的請求來降低介面的耗時。

func (l *ProductDetailLogic) ProductDetail(req *types.ProductDetailRequest) (resp *types.ProductDetailResponse, err error) {
    var (
        p *product.ProductItem
        cs *reply.CommentsResponse
    )
    if err := mr.Finish(func() error {
        var err error
        if p, err = l.svcCtx.ProductRPC.Product(l.ctx, &product.ProductItemRequest{ProductId: req.ProductID}); err != nil {
            return err
        }
        return nil
    }, func() error {
        var err error
        if cs, err = l.svcCtx.ReplyRPC.Comments(l.ctx, &reply.CommentsRequest{TargetId: req.ProductID}); err != nil {
            logx.Errorf("get comments error: %v", err)
        }
        return nil
    }); err != nil {
        return nil, err
    }
    var comments []*types.Comment
    for _, c := range cs.Comments {
        comments = append(comments, &types.Comment{
            ID: c.Id,
            Content:   c.Content,
        })
    }
    return &types.ProductDetailResponse{
        Product: &types.Product{
            ID:        p.ProductId,
            Name:      p.Name,
        },
        Comments: comments,
    }, nil
}

圖片上傳

圖片上傳是非常常用的功能,我們在product-admin中需要上傳商品圖片,這裡我們把商品圖片上傳到阿里雲OSS中,api定義如下

syntax = "v1"

type UploadImageResponse {
    Success bool `json:"success"`
}

service admin-api {
    @handler UploadImageHandler
    post /v1/upload/image() returns (UploadImageResponse)
}

在admin-api.yaml中新增如下配置

Name: admin-api
Host: 0.0.0.0
Port: 8888
OSSEndpoint: https://oss-cn-hangzhou.aliyuncs.com
AccessKeyID: xxxxxxxxxxxxxxxxxxxxxxxx
AccessKeySecret: xxxxxxxxxxxxxxxxxxxxxxxx

新增OSS客戶端

type ServiceContext struct {
    Config config.Config
    OssClient *oss.Client
}

func NewServiceContext(c config.Config) *ServiceContext {
    oc, err := oss.New(c.OSSEndpoint, c.AccessKeyID, c.AccessKeySecret)
    if err != nil {
        panic(err)
    }
    return &ServiceContext{
        Config: c,
        OssClient: oc,
    }
}

上傳邏輯需要先獲取bucket,該bucket為預先定義的bucket,可以通過api呼叫建立,也可以在阿里雲工作臺手動建立

func (l *UploadImageLogic) UploadImage() (resp *types.UploadImageResponse, err error) {
    file, header, err := l.r.FormFile(imageFileName)
    if err != nil {
        return nil, err
    }
    defer file.Close()
    bucket, err := l.svcCtx.OssClient.Bucket(bucketName)
    if err != nil {
        return nil, err
    }
    if err = bucket.PutObject(header.Filename, file); err != nil {
        return nil, err
    }
    return &types.UploadImageResponse{Success: true}, nil
}

使用Postman上傳圖片,注意在上傳圖片前需要先建立bucket

登入阿里雲物件儲存檢視已上傳的圖片

結束語

本篇文章通過日誌定義和服務依賴介紹了服務構建中常見的一些配置,這裡並沒有把所有配置一一列舉而是舉例說明了社群中經常有人問到的場景,後面的文章還會繼續不斷完善服務的相關配置。接著又通過服務依賴的並行呼叫和圖片上傳兩個案例展示了常見功能的優化手段以及編碼方式。

這裡並沒有把所有的功能都列出來,也是想起個頭,大家可以把專案down下來自己去完善這個專案,紙上得來終覺淺,絕知此事要躬行,當然我也會繼續完善專案程式碼和大家一起學習進步。

希望本篇文章對你有所幫助,謝謝。

每週一、週四更新

程式碼倉庫

專案地址

https://github.com/zeromicro/go-zero

https://gitee.com/kevwan/go-zero

歡迎使用 go-zerostar 支援我們!

微信交流群

關注『微服務實踐』公眾號並點選 交流群 獲取社群群二維碼。