牌類遊戲使用微服務重構筆記(四): micro框架使用經驗

段鵬舉發表於2019-02-28

專案依賴

推薦使用go module, 我選擇go module的最主要原因是足夠簡單,可以脫離gopath,就跟寫nodejs一樣,隨便在一個地方新建一個資料夾就可以擼程式碼了,clone下來的原始碼也可以直接跑,不需要設定各種gopath之類的。go-micro原本也是傳統管理依賴來寫的,然後有一個issue裡,作者說他不會把micro專案的依賴管理改成go module,直到go module成為標準。後來,在一夜之間,作者把全部的micro專案都改成了go module。

專案結構

牌類遊戲使用微服務重構筆記(四): micro框架使用經驗

一個模組使用一個大資料夾,其中又分api、cli、srv三個資料夾。srv資料夾用來寫後端微服務,供其他微服務內部訪問;api資料夾用來寫http介面,供使用者訪問;cli資料夾用來寫客戶端, 生成command line程式,介面測試等,各種語言都可以

三層架構

在之前的部落格裡牌類遊戲使用微服務重構筆記(二): micro框架簡介:micro toolkit提過,搭配使用micro api閘道器時,推薦使用三層架構組織服務。

這裡就拿商城為例分享我的方案,筆者沒有做過電商,僅僅是用來舉例,無須在意資料結構的合理性。

  • srv 後端服務

提供最小粒度的服務,一般來說盡可能不考慮業務,如某一類資料的crud。在我的專案中沒有在這一層使用驗證,因為暫時用不到,任何服務都可以直接訪問。

商城裡有商品, 所以有一個提供商品的服務 go.micro.srv.good

syntax = "proto3";

package good;

service GoodSrv {
    // 建立商品
    rpc CreateGood(CreateGoodRequest) returns (CreateGoodResponse) {}

    // 查詢商品
    rpc FindGoods(FindGoodsRequest) returns(FindGoodsResponse) {}
}

// 建立商品請求
message CreateGoodRequest {
    string name = 1; // 名稱
    repeated Image images = 2; // 圖片
    float price = 3; // 價格
    repeated string tagIds = 4; // 標籤
}

// 建立商品響應
message CreateGoodResponse {
    Good good = 1;
}

// 查詢商品請求
message FindGoodsRequest {
    repeated string goodIds = 1;
}

// 查詢商品響應
message FindGoodsResponse {
    repeated Good goods = 1;
}

// 商品資料結構
message Good {
    string id = 1; // id
    string name = 2; // 名稱
    repeated Image images = 3; // 圖片
    float price = 4; // 價格
    repeated string tagIds = 5; // 標籤
}

// 圖片資料結構
message Image {
    string url = 1;
    bool default = 2;
}
複製程式碼

這個服務提供了兩個介面,建立商品、查詢商品。

商品有各種各樣的標籤,再寫一個標籤服務 go.micro.srv.tag

syntax = "proto3";

package tag;

service TagSrv {
    // 獲取標籤
    rpc FindTags (FindTagsRequest) returns (FindTagsResponse) {}
}

// 獲取標籤請求
message FindTagsRequest {
    repeated string tagIds = 1;
}

// 獲取標籤響應
message FindTagsResponse {
    repeated Tag tags = 1;
}

// 標籤資料結構
message Tag {
    string id = 1; // id
    string tag = 2; // 標籤
}
複製程式碼
  • cli

假如要寫一個客戶端程式,獲取並列印商品列表

python

import requests
import json

def main():
    url = "http://localhost:8080/rpc"
    headers = {'content-type': 'application/json'}

    # Example echo method
    payload = {
        "endpoint": "GoodSrv.FindGoods",
        "service": "go.micro.srv.good",
        "request": {}
    }
    response = requests.post(
        url, data=json.dumps(payload), headers=headers).json()

    print response

if __name__ == "__main__":
    main()

複製程式碼

執行輸出

{u'goods': []}
複製程式碼

golang

package main

import (
	"context"
	"github.com/micro/go-micro"
	"log"
	pb "micro-blog/micro-shop/srv/good/proto"
)

func main() {
	s := micro.NewService()
	cli := pb.NewGoodSrvService("go.micro.srv.good", s.Client())
	
	response ,err := cli.FindGoods(context.TODO(), &pb.FindGoodsRequest{GoodIds: []string{"1", "2"}})
	if err != nil {
		panic(err)
	}
	
	log.Println("response:", response)
}

複製程式碼
  • api http介面服務

api層也是微服務,是組裝其他各種微服務,完成業務邏輯的地方。主要提供http介面,如果micro閘道器設定--handler=web 還可以支援websock。現完成一個獲取商品列表的http介面。

proto

syntax = "proto3";

import "micro-blog/micro-shop/srv/good/proto/good.proto";
import "micro-blog/micro-shop/srv/tag/proto/tag.proto";

package shop;

service Shop {
    // 獲取商品
    rpc GetGood(GetGoodRequest) returns(GetGoodResponse) {}
}

// 商城物品
message ShopItem {
    good.Good good = 1;
    repeated tag.Tag tags = 2;
}

// 獲取商品請求
message GetGoodRequest {
    string goodId = 1;
}

// 獲取商品響應
message GetGoodResponse {
    ShopItem item = 1;
}

複製程式碼
使用gin完成api
package main

import (
	"context"
	"github.com/gin-gonic/gin"
	"github.com/micro/go-micro/client"
	"github.com/micro/go-micro/errors"
	"github.com/micro/go-web"
	"log"
	"micro-blog/micro-shop/api/proto"
	pbg "micro-blog/micro-shop/srv/good/proto"
	pbt "micro-blog/micro-shop/srv/tag/proto"
)

// 商城Api
type Shop struct{}

// 獲取商品
func (s *Shop) GetGood(c *gin.Context) {
	id := c.Query("id")

	cli := client.DefaultClient
	ctx := context.TODO()
	rsp := &shop.GetGoodResponse{}

	// 獲取商品
	goodsChan := getGoods(cli, ctx, []string{id})
	goodsReply := <-goodsChan
	if goodsReply.err != nil {
		c.Error(goodsReply.err)
		return
	}

	if len(goodsReply.goods) == 0 {
		c.Error(errors.BadRequest("go.micro.api.shop", "good not found"))
		return
	}

	// 獲取標籤
	tagsChan := getTags(cli, ctx, goodsReply.goods[0].TagIds)
	tagsReply := <-tagsChan
	if tagsReply.err != nil {
		c.Error(tagsReply.err)
		return
	}

	rsp.Item = &shop.ShopItem{
		Good: goodsReply.goods[0],
		Tags: tagsReply.tags,
	}

	c.JSON(200, rsp)
}

// 商品獲取結果
type goodsResult struct {
	err error

	goods []*pbg.Good
}

// 獲取商品
func getGoods(c client.Client, ctx context.Context, goodIds []string) chan goodsResult {
	cli := pbg.NewGoodSrvService("go.micro.srv.good", c)
	ch := make(chan goodsResult, 1)

	go func() {
		res, err := cli.FindGoods(ctx, &pbg.FindGoodsRequest{
			GoodIds: goodIds,
		})
		ch <- goodsResult{goods: res.Goods, err: err}
	}()

	return ch
}

// 標籤獲取結果
type tagsResult struct {
	err error

	tags []*pbt.Tag
}

// 獲取標籤
func getTags(c client.Client, ctx context.Context, tagIds []string) chan tagsResult {
	cli := pbt.NewTagSrvService("go.micro.srv.tag", client.DefaultClient)
	ch := make(chan tagsResult, 1)

	go func() {
		res, err := cli.FindTags(ctx, &pbt.FindTagsRequest{TagIds: tagIds})
		ch <- tagsResult{tags: res.Tags, err: err}
	}()

	return ch
}

func main() {
	// Create service
	service := web.NewService(
		web.Name("go.micro.api.shop"),
	)

	service.Init()

	// Create RESTful handler (using Gin)
	router := gin.Default()

	// Register Handler
	shop := &Shop{}

	router.GET("/shop/goods", shop.GetGood)

	// 這裡的http根路徑要與服務名一致
	service.Handle("/shop", router)

	// Run server
	if err := service.Run(); err != nil {
		log.Fatal(err)
	}
}

複製程式碼
執行
curl -H 'Content-Type: application/json' \
    -s "http://localhost:8080/shop/goods"
複製程式碼
分析
  • 首先使用gin提供的方法從http請求中獲取商品id
  • 向go.micro.srv.good服務發起rpc 獲取商品資訊
  • 向go.micro.srv.tag服務發起rpc 獲取標籤資訊
  • 返回結果
注意

可以發現,如果使用gin,api中的proto定義貌似就沒什麼意義了,因為獲取http請求引數的方法都是gin提供的。如果要使用上這個proto, 可以將micro閘道器的處理器設定為api micro api --handler=api,請求將會自動解析成自己寫的proto結構,詳情可見之前的部落格 牌類遊戲使用微服務重構筆記(二): micro框架簡介:micro toolkit 處理器章節

不過也可以使用gin提供的c.BindJSON c.BindQuery來手動解析成proto結構

Token認證

上文中的獲取商品列表的http請求是沒有任何認證的, 誰都可以進行訪問, 實際專案中可能會有驗證。http驗證的方式非常多,這裡以jsonWebToken舉例實現一個簡單的驗證方法。

實現一個使用者微服務, 提供簽名token和驗證token的rpc方法

proto
syntax = "proto3";
package user;

// 使用者後端微服務
service UserSrv {
    // 簽名token
    rpc SignToken(SignTokenRequest) returns(PayloadToken) {}

    // 驗證token
    rpc VerifyToken(VerifyTokenRequest) returns(PayloadToken) {}
}

// token資訊
message PayloadToken {
    int32 id = 1;
    string token = 2;
    int32 expireAt = 3;
}

// 簽名token請求
message SignTokenRequest {
    int32 id = 1;
}

// 驗證token請求
message VerifyTokenRequest {
    string token = 1;
}
複製程式碼

程式碼完成後,在api裡就可以進行token驗證了

// token 驗證
payload, err := s.UserSrvClient.VerifyToken(context.Background(), &pbu.VerifyTokenRequest{Token: c.GetHeader("Authorization")})
if err != nil {
	c.Error(err)
	return
}
複製程式碼

非常的方便,完全不需要了解認證的程式碼,更沒有響應依賴。如果不想寫的到處都是可以放在中介軟體裡完成

錯誤處理

protoc micro外掛生成的程式碼裡把原生pb檔案包了一層,每個rpc介面都有一個錯誤返回值,如果要丟擲錯誤只需要return錯誤即可

func (g *Greeter) Hello(ctx context.Context, req *proto.HelloRequest, rsp *proto.HelloResponse) error {
	return errors.BadRequest("go.micro.srv.greeter", "test error")
}
複製程式碼

錯誤的定義使用micro包提供的errors包

錯誤結構體

// Error implements the error interface.
type Error struct {
	Id     string `json:"id"` // 錯誤的id 可根據需求自定義
	Code   int32  `json:"code"` // 錯誤碼
	Detail string `json:"detail"` // 詳細資訊
	Status string `json:"status"` // http狀態碼
}

// 實現了error介面
func (e *Error) Error() string {
	b, _ := json.Marshal(e)
	return string(b)
}
複製程式碼

同時也提供了經常用到的各種錯誤型別,如

// BadRequest generates a 400 error.
func BadRequest(id, format string, a ...interface{}) error {
	return &Error{
		Id:     id,
		Code:   400,
		Detail: fmt.Sprintf(format, a...),
		Status: http.StatusText(400),
	}
}


// Unauthorized generates a 401 error.
func Unauthorized(id, format string, a ...interface{}) error {
	return &Error{
		Id:     id,
		Code:   401,
		Detail: fmt.Sprintf(format, a...),
		Status: http.StatusText(401),
	}
}

// Forbidden generates a 403 error.
func Forbidden(id, format string, a ...interface{}) error {
	return &Error{
		Id:     id,
		Code:   403,
		Detail: fmt.Sprintf(format, a...),
		Status: http.StatusText(403),
	}
}

// NotFound generates a 404 error.
func NotFound(id, format string, a ...interface{}) error {
	return &Error{
		Id:     id,
		Code:   404,
		Detail: fmt.Sprintf(format, a...),
		Status: http.StatusText(404),
	}
}
複製程式碼

跨域支援

本地開發的時候,使用micro toolkit會遇到跨域問題。在早期的micro toolkit版本中可以通過micro api --cors=truemicro web --cors=true來允許跨域,後來因為作者說這個支援並不成熟移除了,見issue

目前可以通過go-plugins自己編譯micro得到支援或者其他方式,自定義header也是一樣。micro plugin提供了一些介面,一些特定需求都可以通過外掛來解決

package cors

import (
	"net/http"
	"strings"

	"github.com/micro/cli"
	"github.com/micro/micro/plugin"
	"github.com/rs/cors"
)

type allowedCors struct {
	allowedHeaders []string
	allowedOrigins []string
	allowedMethods []string
}

func (ac *allowedCors) Flags() []cli.Flag {
	return []cli.Flag{
		cli.StringFlag{
			Name:   "cors-allowed-headers",
			Usage:  "Comma-seperated list of allowed headers",
			EnvVar: "CORS_ALLOWED_HEADERS",
		},
		cli.StringFlag{
			Name:   "cors-allowed-origins",
			Usage:  "Comma-seperated list of allowed origins",
			EnvVar: "CORS_ALLOWED_ORIGINS",
		},
		cli.StringFlag{
			Name:   "cors-allowed-methods",
			Usage:  "Comma-seperated list of allowed methods",
			EnvVar: "CORS_ALLOWED_METHODS",
		},
	}
}

func (ac *allowedCors) Commands() []cli.Command {
	return nil
}

func (ac *allowedCors) Handler() plugin.Handler {
	return func(ha http.Handler) http.Handler {
		hf := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ha.ServeHTTP(w, r)
		})

		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			cors.New(cors.Options{
				AllowedOrigins:   ac.allowedOrigins,
				AllowedMethods:   ac.allowedMethods,
				AllowedHeaders:   ac.allowedHeaders,
				AllowCredentials: true,
			}).ServeHTTP(w, r, hf)
		})
	}
}

func (ac *allowedCors) Init(ctx *cli.Context) error {
	ac.allowedHeaders = ac.parseAllowed(ctx, "cors-allowed-headers")
	ac.allowedMethods = ac.parseAllowed(ctx, "cors-allowed-methods")
	ac.allowedOrigins = ac.parseAllowed(ctx, "cors-allowed-origins")

	return nil
}

func (ac *allowedCors) parseAllowed(ctx *cli.Context, flagName string) []string {
	fv := ctx.String(flagName)

	// no op
	if len(fv) == 0 {
		return nil
	}

	return strings.Split(fv, ",")
}

func (ac *allowedCors) String() string {
	return "cors-allowed-(headers|origins|methods)"
}

// NewPlugin Creates the CORS Plugin
func NewPlugin() plugin.Plugin {
	return &allowedCors{
		allowedHeaders: []string{},
		allowedOrigins: []string{},
		allowedMethods: []string{},
	}
}
複製程式碼

修改micro原始碼 新增外掛

package main

import (
    "github.com/micro/micro/plugin"
    "github.com/micro/go-plugins/micro/cors"
)

func init() {
    plugin.Register(cors.NewPlugin())
}
複製程式碼

使用

micro api \
    --cors-allowed-headers=X-Custom-Header \
    --cors-allowed-origins=someotherdomain.com \
    --cors-allowed-methods=POST
複製程式碼

令人疑惑的 NewService

之前的部落格中建立一個後端服務,我們使用了

micro.NewService(
	micro.Name("go.micro.srv.greeter"),
	micro.Version("latest"),
)
複製程式碼

而在api層的微服務,我們使用了

service := web.NewService(
	web.Name("go.micro.api.greeter"),
)
複製程式碼

api層如果使用api處理器

service := micro.NewService(
	micro.Name("go.micro.api.greeter"),
)
複製程式碼

而使用使用grpc(後文會講到,我們又要使用

service := grpc.NewService(
	micro.Name("go.micro.srv.greeter"),
)
複製程式碼

~hat the *uck?
其實這都是micro特意這樣設計的,目的是為了即使從http傳輸改變到grpc, 只需要改變一行程式碼,其他的什麼都不用變(感覺很爽...),後面的部落格原始碼分析會詳細講

之前講過,micro中微服務的名字定義為[名稱空間].[資源型別].[服務名]的,而micro api代理訪問api型別的資源,比如go.micro.api.greeter,micro web代理訪問web型別的資源,比如go.micro.web.greeter

web型別的資源與web.NewService是沒什麼關係的,還是要看資源型別的定義,上文中我們使用到了gin框架或者websocket不使用service提供的server而使用web提供的server因此使用web.NewService來建立服務,後面分析原始碼之後就更清楚了

下面以web.NewService建立一個websocket服務並使用micro api代理來舉例

package main

import (
	"github.com/micro/go-web"
	"gopkg.in/olahol/melody.v1"
	"log"
	"net/http"
)

func main() {
	// New web service
	service := web.NewService(
		web.Name("go.micro.api.gateway"),
	)

	// parse command line
	service.Init()

	m := melody.New()
	m.HandleDisconnect(HandleConnect)

	// Handle websocket connection
	service.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		m.HandleRequest(w, r)
	})

	// run service
	if err := service.Run(); err != nil {
		log.Fatal("Run: ", err)
	}
}

// 處理使用者連線
func HandleConnect(session *melody.Session) {
	log.Println("new connection ======>>>")
}

複製程式碼

瀏覽器程式碼

wsUri  = "ws://" + "localhost:8080/gateway"

var print = function(message) {
    var d       = document.createElement("div");
    d.innerHTML = message;
    output.appendChild(d);
};

  var newSocket = function() {
    ws           = new WebSocket(wsUri);
    ws.onopen = function(evt) {
      print('<span style="color: green;">Connection Open</span>');
    }
    ws.onclose = function(evt) {
      print('<span style="color: red;">Connection Closed</span>');
      ws = null;
    }
    ws.onmessage = function(evt) {
        print('<span style="color: blue;">Onmessage: </span>' + parseCount(evt));
    }

    ws.onerror = function(evt) {
      print('<span style="color: red;">Error: </span>' + parseCount(evt));
    }
 };
  
複製程式碼

可以正常連線到websocket(我在專案中是使用micro web來代理websocket 這裡僅僅是舉例)

本章未完待續

一下想不完使用經驗,後續想到哪裡會再補充

本人學習golang、micro、k8s、grpc、protobuf等知識的時間較短,如果有理解錯誤的地方,歡迎批評指正,可以加我微信一起探討學習

牌類遊戲使用微服務重構筆記(四): micro框架使用經驗

相關文章