go-micro微服务实践(一)


目录

一、go-micro框架

二、服务注册发现(etcd)

三、服务网关

四、链路追踪(jaeger)

五、protobuf协议

六、部署(docker-compose)

七、其他

八、错误总结


github完整代码示例:https://github.com/catwrench/go-micro
内容比较多,多看代码示例,有空再来拆分成多篇文章细说


参考资料

  • go-micro中文文档

安装 |《Go Micro 中文文档 2.x》| Go 技术论坛

  • micro组件

micro/micro

  • 官方示例

microhq/examples

  • go-plugins支持示例

microhq/go-plugins

  • go-micro实践

Golang微服务开发实践

go-micro V2 从零开始_hotcoffie的博客-CSDN博客


一、go-micro框架

micro 是一套微服务构建工具库。对于微服务架构的应用,micro 提供平台层面、高度弹性的工具组件,让服务开发者们可以把复杂的分布式系统以简单的方式构建起来,并且尽可能让开发者使用最少的时间完成基础架构的构建。

go-micro 是独立的 rpc 框架,它是 micro 工具集的核心

micro的服务核心组件:

  • Client:发送RPC请求与广播消息

  • Server:接收RPC请求与消费消息

  • Broker:异步通信组件

  • Codec:数据编码组件

  • Registry:服务注册组件

  • Selector:客户端均衡器

  • Transport:同步通信组件

其中,BrokerTransport都是通讯组件,区别就是一个是异步,另一个是同步。所以我们在业务中使用Transport组件会比较频繁,因为业务需要关心调用结果。

Codec是数据编码组件,它可以自动将请求及其参数,转换为需要的格式。例如,当我们需要调用其他服务时,Transport默认的是使用grpc2,grpc2使用的通讯格式是Protobuf,所以Codec会帮我们将数据转为Protobuf格式进行发送。

Registry是服务注册组件,它既可以帮我们把我们的服务注册到服务中心,又可以在服务中心中获取已经注册的服务列表,供我们进行调用。

Selector客户端均衡器配合服务注册组件。当从服务中心中获取已经注册的服务列表时,由于相同的一个服务可能是高可用的架构,所以需要一个均衡调度器,根据不同的均衡权重算法,来帮我们选择一个合适的节点进行调用。


二、服务注册发现(etcd)

go-micro框架为服务注册发现提供了标准的接口Registry。只要实现这个接口就可以定制自己的服务注册和发现。不过官方已经为主流注册中心提供了官方的接口实现

目前最新版的go-micro默认使用mDNS 提供零配置的发现系统,他内置于大多数系统。所以之前我们的程序完全不用做任何配置,也不用搭建任何环境,就具备服务注册和发现能力。

而在生产环境,官方则推荐使用etcd组成更具弹性的集群方案,在v2版中,官方已经不推荐使用consul。

  • 配置默认使用etcd

go run main --registry=etcd

通过设置 GoLand 的 Go Modules 环境变量 MICRO_REGISTRY=etcd 来统一设置,这样,就不需要在启动服务时额外传入--registry=etcd这个选项了(打开 GoLand 的 Preferences 界面即可完成设置)

动手实践

  • 注册(示例是etcd的,替换成consul也是一样的)
func init()  {
    //注册地址为 ip:端口
    etcdRegister := etcd.NewRegistry(
        registry.Addrs("192.168.110.195:2379"),
    )
}
  • 发现
//从注册中心获取服务节点
func getSrvNode(reg registry.Registry, srvName string) (*registry.Node, error) {
    //获取服务列表
    services, err := reg.GetService(srvName)
    if err != nil {
        log.Info("未获取到服务 " + srvName + ",请确认服务是否存在")
        return nil, err
    }
    //获取随机服务,也可以使用 RoundRobin 之类的算法
    next := selector.Random(services)
    node, err := next()
    if err != nil {
        log.Info("随机获取服务 " + srvName + " 实例失败")
        return nil, err
    }
    return node, nil
}

三、服务网关

Micro的api就是api网关,API参考了API网关模式为服务提供了一个单一的公共入口。基于服务发现,使得micro api可以提供具备http及动态路由的服务。

micro 自带的api网关功能比较单一,而且http和rpc请求需要起不同的服务来处理,服务网关通常会整合很多系统相关逻辑,所以是使用的gin框架自行实现的网关。

动手实践

  • main.go
    核心是初始化一个服务注册到etcd,然后启动一个web服务对外提供api访问,同时将gin.router作为处理器绑定到go-micro,让gin框架来接管路由
func main() {
    ......
    //etcd注册实例
    etcdRegister := etcd.NewRegistry(
        registry.Addrs(etcdAddr),
    )
    //----------------注册网关-----------------------------------

    //注册网关服务
    //这个服务实际不会调用.run方法,实际会启动的是下面的webService
    gwtService := micro.NewService(
        micro.Name(gatewayName),
        micro.Version("latest"),
        // 配置etcd为注册中心,配置etcd路径,默认端口是2379
        micro.Registry(etcdRegister),
    )

    //---------------注册web服务-------------------------------
    //会议室预订服务的restful api映射
    webService := web.NewService(
        web.Name(gatewayWeb),
        web.Address(webServiceAddr),
        web.Registry(etcdRegister),
    )
    //注册路由处理器
    webService.Handle("/", router.NewRouter())

    //启动服务
    if err := webService.Run(); err != nil {
        fmt.Println("webService.Run error:", err)
    }
    ......
}
  • route.go
//返回gin router
func NewRouter() *gin.Engine {
    route := gin.Default()

    //跨域处理
    route.Use(middleware.Cors())

    //通配路由,以 meeting 为前缀的都转发到会议室预订服务去
    uriMeeting := serviceclient.MeetingApiNode.Address
    route.Any("/api/meeting/*any", ReverseProxy(uriMeeting, ""))
    route.Any("/api/meetingApplet/*any", ReverseProxy(uriMeeting, ""))

    return route
}

//反向代理
func ReverseProxy(host string, scheme string) gin.HandlerFunc {
    return func(context *gin.Context) {
        director := func(req *http.Request) {
            if scheme == "" {
                scheme = "http"
            }
            req.URL.Scheme = scheme
            req.URL.Host = host
            req.Host = host //一个ip对应多个域名的情况需要设置这项
        }
        proxy := &httputil.ReverseProxy{Director: director}
        proxy.ServeHTTP(context.Writer, context.Request)
    }
}

四、链路追踪(jaeger)

中文文档 介绍

工作原理:

动手实践

  • 改造一下网关的main.go
    ......
    //----------------注册网关-----------------------------------
    // 配置jaeger连接
    jaegerTracer, closer, err := tracer.NewJaegerTracer(gatewayName, jaegerAddr)
    if err != nil {
        log.Fatal(err)
    }
    defer closer.Close()
    wrapperTrace.SetGlobalTracer(jaegerTracer)

    //注册网关服务
    //这个服务实际不会调用.run方法,实际会启动的是下面的webService
    gwtService := micro.NewService(
        ......
        // 配置链路追踪为 jaeger
        micro.WrapHandler(opentracing.NewHandlerWrapper(wrapperTrace.GlobalTracer())),
    )
  • 改造一下路由router.go,加入链路追踪中间件
func NewRouter() *gin.Engine {
    ......
    route.Use(
        lib.JaegerMiddleware(),               //jaeger中间件
    )
    ......
}
  • JaegerMiddleware链路追踪中间件
//jaeger中间件,记录token和span信息
func JaegerMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        sp := opentracing.GlobalTracer().StartSpan(c.Request.URL.Path)
        tracer := opentracing.GlobalTracer()
        //元数据metadata
        md := make(map[string]string)
        //获取请求投的 spanCtx
        spanCtx, sErr := opentracing.GlobalTracer().Extract(opentracing.HTTPHeaders, opentracing.HTTPHeadersCarrier(c.Request.Header))
        if sErr != nil {
            sp = opentracing.GlobalTracer().StartSpan(c.Request.URL.Path, opentracing.ChildOf(spanCtx))
            tracer = sp.Tracer()
        }
        //基于提取的 spanCtx 创建新的子span
        sp = opentracing.GlobalTracer().StartSpan(c.Request.URL.Path, opentracing.ChildOf(spanCtx))
        sp.SetTag("Authorization", c.GetHeader("Authorization"))
        defer sp.Finish()

        //注入span到tracer.text
        if err := tracer.Inject(
            sp.Context(),
            opentracing.TextMap,
            opentracing.TextMapCarrier(md),
        ); err != nil {
            log.Log(err)
        }
        //注入span到tracer.text
        if err := tracer.Inject(
            sp.Context(),
            opentracing.HTTPHeaders,
            opentracing.HTTPHeadersCarrier(c.Request.Header),
        ); err != nil {
            log.Log(err)
        }

        ctx := context.TODO()
        ctx = opentracing.ContextWithSpan(ctx, sp)
        ctx = metadata.NewContext(ctx, md)
        c.Set(contextTracerKey, ctx)

        c.Next()

        //通过ext可以为追踪设置额外的一些信息
        statusCode := c.Writer.Status()
        ext.HTTPStatusCode.Set(sp, uint16(statusCode))
        ext.HTTPMethod.Set(sp, c.Request.Method)
        ext.HTTPUrl.Set(sp, c.Request.URL.EscapedPath())
        if statusCode >= http.StatusInternalServerError {
            ext.Error.Set(sp, true)
        } else if rand.Intn(100) > sf {
            ext.SamplingPriority.Set(sp, 0)
        }
    }
}

// ContextWithSpan 返回context
func ContextWithSpan(c *gin.Context) (ctx context.Context, ok bool) {
    v, exist := c.Get(contextTracerKey)
    if exist == false {
        ok = false
        ctx = context.TODO()
        return
    }
    ctx, ok = v.(context.Context)
    return
}

五、protobuf协议

工作流程:

  • Proto 语言文件的规范
proto 文件遵循只增不减的原则
proto 文件中的接口遵循只增不减的原则
proto 文件中的 message 字段遵循只增不减的原则
proto 文件中的 message 字段类型和序号不得修改
  • 官方文档

golang/protobuf

  • 服务间调用依赖的proto文件如何进行管理

微服务架构下RPC IDL及代码如何统一管理?_韩亚军的博客-CSDN博客

动手实践

这里需要创建两个子服务,meeting-apimeeting-srv,meeting-srv实现会议室预订相关的CRUD,meeting-api通过rpc远程调用meeting-srv完成业务组装,并提供对外部的api访问。(需要两个项目持有同一份proto文件,才能通过protobuf协议完成 rpc调用)

  • 定义响应的公共文件response.proto
syntax = "proto3";
package response;
import "google/protobuf/any.proto";

option go_package = ".;proto";

message Response  {
  int64 Code = 1;
  string Message = 2;
  google.protobuf.Any Data = 3;
}
  • 定义会议室room.proto文件
syntax = "proto3";
package meeting;
import "proto/response/response.proto";//标红无所谓,一样能导入
option go_package = ".;proto";

service RoomService{
  //查询会议室列表
  rpc GetRooms(ReqGetRooms) returns(response.Response){}
  //查询会议室详情
  rpc GetRoom(ReqGetRoom) returns(response.Response){}
  //新增会议室
  rpc CreateRoom(ReqCreateRoom) returns(response.Response){}
  //编辑会议室
  rpc UpdateRoom(ReqUpdateRoom) returns(response.Response){}
  //删除会议室
  rpc DeleteRoom(ReqDeleteRoom) returns(response.Response){}
}

message Room{
  int64 id = 1;
  int64 space_id = 2;//所属地点ID
  string name = 3;//会议室名称
  oneof one_status{
    string status = 4;// 启用状态:0禁用、1启用
  };
  string image_url = 5;//会议室图片
  int64 capacity_min = 6;//建议使用人数(最小)
  int64 capacity_max = 7;//建议使用人数(最大)
  string created_at = 8;
}

message ReqGetRooms{
  int64 page = 1;
  int64 pageSize = 2;
  string sortBy = 3;
  string order = 4;
  int64 space_id = 5;
  string name = 6;
  oneof one_status{
    string status = 7;
  };
}

message ReqGetRoom{
  int64 id = 1;
}

message ReqCreateRoom{
  int64 space_id = 2;
  string name = 3;
  oneof one_status{
    string status = 4;
  };
  string image_url = 5;
  int64 capacity_min = 6;
  int64 capacity_max = 7;
  string DeviceIds = 8;
}

message ReqUpdateRoom{
  int64 id = 1;
  int64 space_id = 2;
  string name = 3;
  oneof one_status{
    string status = 4;
  };
  string image_url = 5;
  int64 capacity_min = 6;
  int64 capacity_max = 7;
  string DeviceIds = 8;
}

message ReqDeleteRoom{
  int64 id = 1;
}
  • 在common根目录下生成所有proto文件命令:(注意执行路径和输入输出路径)
for x in **/*.proto; do protoc --go_out=protob --micro_out=protob $x; done

六、部署(docker-compose)

直接参考github代码吧:https://github.com/catwrench/go-micro/tree/main/deploy

ps:开始前请确认
1、将common服务复制到每个项目的submodules路径下(使用git submodules引入公共模块,配置其实可以使用etcd存储)
2、是否将`submodules/common/config/config.dev.yml`重命名为`config.yml`,并填写正确参数
3、确认`deploy/.env`是否配置正确

# 先构建golang基础镜像,打上标签
cd golang
docker build -t golang:base .
docker tag golang:base golang-base:1.14.4

# 通过docker-compose 构建微服务镜像并启动电容器
# 在deploy根目录执行
docker-compose up --build -d  
  • 启动后的效果


  • deploy/.env

# docker-compose 环境配置

# go-micro公共配置
WORKSPACE="../../go-micro"
MICRO_REGISTRY="etcd"
MICRO_SERVER_ADDRESS=":2379"

# MYSQL配置
MYSQL_DATABASE=micro_dev
MYSQL_PORT=3306
MYSQL_ROOT_USER=root
MYSQL_ROOT_PASSWORD=root
MYSQL_TEST_USER=test
MYSQL_TEST_PASSWORD=test
MYSQL_DATA_DIR=./db_data
MYSQL_LOG=./log/mysql

# REDIS配置
REDIS_PORT=6379
REDIS_PASSWORD=null

#-------------------------------------
# 注册中心
ALLOW_NONE_AUTHENTICATION="yes"
ETCD_ADVERTISE_CLIENT_URLS="http://etcd:2379"
ETCD_LISTEN_CLIENT_URLS="http://0.0.0.0:2379"
ETCD_LISTEN_PEER_URLS="http://0.0.0.0:2380"
ETCD_INITIAL_ADVERTISE_PEER_URLS="http://0.0.0.0:2380"
ETCD_INITIAL_CLUSTER="http://0.0.0.0:2380"
ETCD_INITIAL_CLUSTER_TOKEN="etcd-cluster"
ETCD_INITIAL_CLUSTER_STATE="new"
ETCD_NAME="node1"

# 网关
GATEWAY_PORT=8091

# 会议室预订服务【api】
MEETING_API_PORT=56201

# 会议室预订服务【srv】
MEETING_SRV_PORT=56302

# 用户服务【srv】
USER_SRV_PORT=56301

# 消息通知服务【srv】
NOTICE_SRV_PORT=56303
  • deploy/docker-compose.yml
version: '3.1'

services:
  #mysql服务
  db:
    build: ./mysql
    command: --default-authentication-plugin=mysql_native_password
    volumes:
      - ${MYSQL_DATA_DIR}:/var/lib/mysql
      - ${MYSQL_LOG}:/var/log/mysql
    ports:
      - "${MYSQL_PORT}:3306"
    environment:
      #mysql的root密码
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      #容器会创建的数据库
      MYSQL_DATABASE: ${MYSQL_DATABASE}
      #test用户
      MYSQL_USER: ${MYSQL_TEST_USER}
      #test用户的密码
      MYSQL_PASS: ${MYSQL_TEST_PASSWORD}
    networks:
      - gomicro

  #redis服务
  redis:
    build: ./redis
    ports:
      - "${REDIS_PORT}:6379"
      #指定创建redis容器后,设置的密码
    #command:
    #  - "--requirepass Admin@${REDIS_PASSWORD}"
    networks:
      - gomicro

  # ------------------------------
  # 注册中心 etcd
  etcd:
    image: bitnami/etcd:3
    ports:
      - 2379:2379
      - 2380:2380
    environment:
      ALLOW_NONE_AUTHENTICATION: ${ALLOW_NONE_AUTHENTICATION}
      ETCD_ADVERTISE_CLIENT_URLS: ${ETCD_ADVERTISE_CLIENT_URLS}
      ETCD_LISTEN_CLIENT_URLS: ${ETCD_LISTEN_CLIENT_URLS}
      ETCD_LISTEN_PEER_URLS: ${ETCD_LISTEN_PEER_URLS}
      ETCD_INITIAL_ADVERTISE_PEER_URLS: ${ETCD_INITIAL_ADVERTISE_PEER_URLS}
      ETCD_INITIAL_CLUSTER: "${ETCD_NAME}=${ETCD_INITIAL_CLUSTER}"
      ETCD_INITIAL_CLUSTER_TOKEN: ${ETCD_INITIAL_CLUSTER_TOKEN}
      ETCD_INITIAL_CLUSTER_STATE: ${ETCD_INITIAL_CLUSTER_STATE}
      ETCD_NAME: ${ETCD_NAME}
    networks:
      - gomicro

  # 链路追踪 jaeger
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - 16686:16686
    networks:
      - gomicro

  # 网关
  gateway:
    build:
      # 设置context为上级相对路径,避免dockerfile构建时add命令不能添加上级目录
      context: ../
      dockerfile: gateway/deploy/Dockerfile
      args:
        - EXPOSE_PORT=${GATEWAY_PORT}
    ports:
      - "${GATEWAY_PORT}:${GATEWAY_PORT}"
    environment:
      MICRO_REGISTRY: ${MICRO_REGISTRY}
      MICRO_SERVER_ADDRESS: ":${GATEWAY_PORT}"
    volumes:
      - "${WORKSPACE}:/go/src/go-micro"
    depends_on:
      - etcd
      - jaeger
      - meeting-api
    networks:
      - gomicro

  # ------服务列表------
  # 会议室预订服务【api】
  meeting-api:
    build:
      context: ../
      dockerfile: meeting-api/deploy/Dockerfile
      args:
        - EXPOSE_PORT=${MEETING_API_PORT}
    ports:
      - "${MEETING_API_PORT}:${MEETING_API_PORT}"
    environment:
      MICRO_REGISTRY: ${MICRO_REGISTRY}
      MICRO_SERVER_ADDRESS: ":${MEETING_API_PORT}"
    volumes:
      - "${WORKSPACE}:/go/src/go-micro"
    depends_on:
      - etcd
      - jaeger
      - meeting-srv
    networks:
      - gomicro

  # 会议室预订服务【srv】
  meeting-srv:
    build:
      context: ../
      dockerfile: meeting-srv/deploy/Dockerfile
      args:
        - EXPOSE_PORT=${MEETING_SRV_PORT}
    ports:
      - "${MEETING_SRV_PORT}:${MEETING_SRV_PORT}"
    environment:
      MICRO_REGISTRY: ${MICRO_REGISTRY}
      MICRO_SERVER_ADDRESS: ":${MEETING_SRV_PORT}"
    volumes:
      - "${WORKSPACE}:/go/src/go-micro"
    depends_on:
      - etcd
      - db
    networks:
      - gomicro

  # 用户服务【srv】
  user-srv:
    build:
      context: ../
      dockerfile: user-srv/deploy/Dockerfile
      args:
        - EXPOSE_PORT=${USER_SRV_PORT}
    ports:
      - "${USER_SRV_PORT}:${USER_SRV_PORT}"
    environment:
      MICRO_REGISTRY: ${MICRO_REGISTRY}
      MICRO_SERVER_ADDRESS: ":${USER_SRV_PORT}"
    volumes:
      - "${WORKSPACE}:/go/src/go-micro"
    depends_on:
      - etcd
    networks:
      - gomicro

  # 消息通知服务【srv】
  notice-srv:
    build:
      context: ../
      dockerfile: notice-srv/deploy/Dockerfile
      args:
        - EXPOSE_PORT=${NOTICE_SRV_PORT}
    ports:
      - "${NOTICE_SRV_PORT}:${NOTICE_SRV_PORT}"
    environment:
      MICRO_REGISTRY: ${MICRO_REGISTRY}
      MICRO_SERVER_ADDRESS: ":${NOTICE_SRV_PORT}"
    depends_on:
      - etcd
      - db
      - redis
    volumes:
      - "${WORKSPACE}:/go/src/go-micro"
    networks:
      - gomicro

networks:
  gomicro:
    driver: bridge


七、其他组件

具体使用参考github仓库代码,限于篇幅这里就不写了

  • 参数验证(validator/v10, gin框架默认组件)

golang常用库:字段参数验证库-validator使用 - 九卷 - 博客园

  • gorm

GORM 指南

  • 如何使用gorm编写良好可复用代码

利用go+grpc+gorm+proto、通过设计好的数据表快速生成curd增删改查代码_陈福华的博客-CSDN博客

  • 读取配置文件(viper)

spf13/viper

  • 时间格式转换(golang-module/carbon,不推荐用,因为错误全部抛出panic)

golang-module/carbon


八、错误总结

  • 总结:
  1. web服务(消费者)和api服务(提供者)一定是分开的两个服务,一个以web.newService声明,一个以micro.newService声明

  2. web服务无法注册链路追踪,解决办法为:micro.newService创建一个普通服务并注册到注册中心,但是不启动,实际启动的是web服务

  3. rpc远程调用通常为 res := client.call(service.func, &req ,&parms)

  4. 在Micro api功能中,支持多种处理请求路由的方式,我们称之为Handler。包括:API Handler、RPC Handler、反向代理、Event Handler,RPC等五种方式

  5. 网关将请求根据路由前缀批量转发到api服务(如:mcms、uims),然后由api服务(消费者)进行匹配,调用业务服务(提供者)

  6. git代码仓库每个服务分开,可以考虑使用git submodule来进行管理公共服务

  7. web.NewService和micro.NewService的区别

  • 可能的错误
1、protobuf缺省值问题
原因:proto3传输时,0、""、null会被忽略
解决:可以使用one_of,并且将字段类型设置为string,避免int类型默认值被设置为0 
https://developers.google.com/protocol-buffers/docs/proto3#oneof

2、gorm使用update进行更新struct时忽略了0值
原因:update存储时会先将struct转map,转换过程中0值会被忽略
解决一:使用save方法进行保存
解决二:使用update方法进行保存,但传入参数手动转换为map类型

3、引入viper组件,读取配置文件后,开启调试模式报错
原因:ide配置错误
解决:debug配置->go build->Run kind(package)->working directory(debug服务的根目录)

4、读取ctx.request.body时报错"unexpected EOF"
原因:将数据重新写入body,因为readall读取一次后就不在了,会导致后面的api服务读取body是报错"unexpected EOF"
解决一:ctx.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body))
解决二:在gin 1.4 之前,重复使用ShouldBind绑定会报错EOF。
gin 1.4 之后官方提供了一个 ShouldBindBodyWith 的方法,可以支持重复绑定,
原理就是将body的数据缓存了下来,但是二次取数据的时候还是得用 ShouldBindBodyWith 
才行,直接用 ShouldBind 还是会报错的。

5、启动服务报错:panic: qtls.ConnectionState not compatible with tls.ConnectionState
原因:go-micro的部分组件还未对go1.15做适配
解决:使用低于1.15版本的go

6、报错:command not found: micro
原因:在gopath/bin目录下没有micro二进制文件 或 未配置系统环境变量
解决:https://blog.csdn.net/m0_38025165/article/details/106865383

7、报错:undefined: balancer.PickOptions 
原因:依赖冲突
解决:replace google.golang.org/grpc => google.golang.org/grpc v1.26.0

8、生成proto文件报错:handler/hello.go:8:2: package hello/proto/hello is not in GOROOT (/usr/local/go/src/hello/proto/hello)
解决:其实不是GOROOT的问题,是对应的proto文件没有生成,进入到服务根目录下,执行make proto

你可能感兴趣的:(go-micro微服务实践(一))