go微服务系列(十):Go RPC实现服务间通信


Go RPC 是指 Go 语言原生支持的 RPC 框架,它虽然简单但却十分经典

Go 语言 RPC 过程调用实践

Go 语言原生的 RPC 过程调用实现起来非常简单。服务端只需实现对外提供的远程过程方法和结构体,然后将其注册到 RPC 服务中,客户端就可以通过其服务名称和方法名称进行 RPC 方法调用。
使用字符串操作的服务来展示如何使用 Go 语言原生的 RPC 来进行过程调用。
第一步,定义远程过程调用相关接口传入参数和返回参数的数据结构。如下代码所示,调用字符串操作的请求包括两个参数:字符串 A 和字符串 B。

type StringRequest struct {
    
    
  A string
  B string
}

第二步,定义一个服务对象。这个服务对象可以很简单,比如类型是 int 或者是 interface{},重要的是它输出的方法。这里我们定义一个字符串服务类型的 interface,其名称为 Service,它有一个字符串拼接函数 Concat;然后定义一个名为 StringService 的结构体,实现 Service 接口,并给出 Concat 具体实现。代码如下:

type Service interface {
    
    
 // Concat a and b
 Concat(req StringRequest, ret string) error
}
type StringService struct {
    
    
}
func (s StringService) Concat(req StringRequest, ret string) error {
    
    
 // test for length overflow
 if len(req.A)+len(req.B) > StrMaxSize {
    
    
   ret = ""
   return ErrMaxSize
 }
 ret = req.A + req.B
 return nil
}

第三步,实现 RPC 服务器。这里我们生成了一个 StringSevice 结构体,并使用 rpc.Register 注册这个服务,然后通过 net.Listen 监听对应 socket 并对外提供服务。客户端可以访问服务 StringService 以及它的方法 Concat,代码如下:

func main() {
    
    
 stringService := new(service.StringService)
 rpc.Register(stringService)
 rpc.HandleHTTP()
 l, e := net.Listen("tcp", "127.0.0.1:1234")
 if e != nil {
    
    
   log.Fatal("listen error:", e)
 }
 http.Serve(l, nil)
}

第四步,建立 HTTP 客户端,然后通过 Call 方法调用远程 StringService 的对应方法,比如使用同步的方式,代码如下所示。这时客户端就可以进行远程调用了。

func main() {
    
    
 client, err := rpc.DialHTTP("tcp", "127.0.0.1:1234")
 if err != nil {
    
    
   log.Fatal("dialing:", err)
 }
 stringReq := &service.StringRequest{
    
    "A", "B"}
 var reply string
 err = client.Call("StringService.Concat", stringReq, &reply)
 if err != nil {
    
    
   log.Fatal("Concat error:", err)
 }
 // 异步的调用方式 
 call := client.Call("StringService.Concat", stringReq, &reply)
  := <-call.Done
}

放一下实线上面功能的源码

server.go:
package main

import (
	service "github.com/longjoy/micro-go-course/section16/service"   //这里导入的是外部的包,不用build编译外部包,使用本地包或者自定义的本地宝时需要编译成归档文件,不然编译器找不到
	"log"
	"net"
	"net/http"
	"net/rpc"
)

func main() {
    
    
	stringService := new(service.StringService)
	rpc.Register(stringService)
	rpc.HandleHTTP()
	l, e := net.Listen("tcp", "127.0.0.1:1234")
	if e != nil {
    
    
		log.Fatal("listen error:", e)
	}
	http.Serve(l, nil)
}


client.go:
package main

import (
	"fmt"
	service "github.com/longjoy/micro-go-course/section16/service"
	"log"
	"net/rpc"
)

func main() {
    
    

	client, err := rpc.DialHTTP("tcp", "127.0.0.1:1234")

	if err != nil {
    
    
		log.Fatal("dialing:", err)
	}

	stringReq := &service.StringRequest{
    
    "A", "B"}
	var reply string
	err = client.Call("StringService.Concat", stringReq, &reply)
	fmt.Printf("StringService Concat : %s concat %s = %s\n", stringReq.A, stringReq.B, reply)
	if err != nil {
    
    
		log.Fatal("Concat error:", err)
	}
	// 异步的调用方式
	call := client.Go("StringService.Concat", stringReq, &reply, nil)
	_ = <-call.Done
	fmt.Printf("StringService Concat : %s concat %s = %s\n", stringReq.A, stringReq.B, reply)
}

go.mod:
module github.com/longjoy/micro-go-course/section16

go 1.12

require (
	github.com/go-kit/kit v0.10.0
	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
)

接下来的common.go和service.go处于service文件夹中,这个文件夹和上面的三个文件处于同一级目录
common.go:
package service

type StringRequest struct {
    
    
	A string
	B string
}

service.go:
package service

import (
	"errors"
)

const (
	StrMaxSize = 1024
)

// Service errors
var (
	ErrMaxSize = errors.New("maximum size of 1024 bytes exceeded")

	ErrStrValue = errors.New("maximum size of 1024 bytes exceeded")
)

type Service interface {
    
    
	// Concat a and b
	Concat(req StringRequest, ret *string) error
}

type StringService struct {
    
    
}

func (s StringService) Concat(req StringRequest, ret *string) error {
    
    
	// test for length overflow
	if len(req.A)+len(req.B) > StrMaxSize {
    
    
		*ret = ""
		return ErrMaxSize
	}
	*ret = req.A + req.B
	return nil
}

注意使用时你可能需要对service文件夹进行一次go build

通过上述代码的编写就可以实现两个 Go 服务之间的 RPC 调用了。那 Go RPC 又是如何实现的呢?

Go RPC 原理解析

接下来我们将对 Go 语言的 RPC 原生实现进行源码分析,细致讲解其具体实现和原理。首先我们会对 RPC 的服务(Server)端代码进行分析,包括注册服务、反射处理和存根保存,然后讲解服务端处理 RPC 请求的流程,最后讲解客户(Client)端的 RPC 请求处理。

Go RPC 服务端原理

服务端的 RPC 代码主要分为两个部分:①服务方法注册,包括调用注册接口,通过反射处理将方法取出,并存到 map 中;②处理网络调用,主要是监听端口、读取数据包、解码请求和调用反射处理后的方法,将返回值编码,返回给客户端。

在上面的示例代码中,我们使用 rpc.Register 对 StringService 进行了注册,Register 是进行 RPC 服务注册的入口方法,其参数 interface{} 类型的 rcvr 就是要注册的 RPC 服务类型,该注册过程的流程如下图所示。
在这里插入图片描述
Register 方法中通过反射获取接口类型和值,并通过 suitableMethods 函数判断注册的 RPC 是否符合规范,最后调用 serviceMap 的 LoadOrStore(sname, s) 方法将对应 RPC 存根存放于 map 中,供之后查找。

扫描二维码关注公众号,回复: 14345568 查看本文章
func (server Server) register(rcvr interface{
    
    }, name string, useName bool) error {
    
    
  // 如果服务为空,默认注册一个 
  if server.serviceMap == nil {
    
     
    server.serviceMap = make(map[string]service) 
  }
  // 获取注册服务的反射信息 
  s := new(service) 
  s.typ = reflect.TypeOf(rcvr) 
  s.rcvr = reflect.ValueOf(rcvr) 
  // 可以使用自定义名称 
  sname := reflect.Indirect(s.rcvr).Type().Name() 
  if useName {
    
     
    sname = name 
  } 
  // 方法必须是暴露的,既服务名首字符大写;不允许重复注册。代码有省略
  if !isExported(sname) && !useName {
    
     
  }
  if , present := server.serviceMap[sname]; present {
    
     
  } 

  s.name = sname 
  // 开始注册 rpc struct 内部的方法存根 
  s.method = suitableMethods(s.typ, true) 
  if len(s.method) == 0 {
    
     
     // 如果struct内部一个方法也没,那么直接报错,打印详细的错误信息
  }
  // 保存在server的serviceMap中 
  server.serviceMap[s.name] = s 
  return nil 
} 

接下来,我们来看一下服务端处理 RPC 请求的实现。如下图就展示了服务端 RPC 程序处理请求的过程,它会一直循环处理接收到的客户端 RPC 请求,将其交由 ReadRequestHandler 处理,然后从之前 Register 方法保存的 map 中获取到要调用的对应方法;接着从请求中解码出对应的参数,使用反射调用其方法,获取到结果后将结果编码成响应消息返回给客户端。
在这里插入图片描述

接收请求

Server 的 Accept 函数会无限循环地调用 net.Listener 的 Accept 函数来获取客户端建立连接的请求,获取到连接请求后,再使用协程来处理请求。代码如下:

func (server Server) Accept(lis net.Listener) {
    
     
  for {
    
     
    conn, err := lis.Accept() 
    if err != nil {
    
     
      log.Fatal("rpc.Serve: accept:", err.Error())
    }
    // accept连接以后,打开一个goroutine处理请求 
    go server.ServeConn(conn) 
  } 
} 

读取并解析请求

ServeConn 函数会从建立的连接中读取数据,然后创建一个 gobServerCodec,并将其交由 Server 的 ServeCodec 函数处理,如下所示:

func (server Server) ServeConn(conn io.ReadWriteCloser) {
    
     
  buf := bufio.NewWriter(conn) 
  srv := &gobServerCodec{
    
     
    rwc:  conn, 
    dec:  gob.NewDecoder(conn), 
    enc:  gob.NewEncoder(buf), 
    encBuf: buf, 
  } 
  // 根据指定的codec进行协议解析 
  server.ServeCodec(srv) 
} 

ServeCodec 函数会循环地调用 readRequest 函数,读取网络连接上的字节流,解析出请求,然后开启协程执行 Server 的 call 函数,处理对应的 RPC 调用。

func (server Server) ServeCodec(codec ServerCodec) {
    
     
  sending := new(sync.Mutex) 
  for {
    
     
    // 解析请求 
    service, mtype, req, argv, replyv, keepReading, err := server.readRequest (codec)
    if err != nil {
    
     
      if debugLog && err != io.EOF {
    
     
        log.Println("rpc:", err) 
      } 
      if !keepReading {
    
     
        break 
      } 
      // send a response if we actually managed to read a header. 
      // 如果当前请求错误了,我们应该返回信息,然后继续处理 
      if req != nil {
    
     
        server.sendResponse(sending, req, invalidRequest, codec, err.Error())
        server.freeRequest(req) 
      } 
      continue 
    } 
    // 因为需要继续处理后续请求,所以开一个gorutine处理rpc方法 
    go service.call(server, sending, mtype, req, argv, replyv, codec) 
  }
  // 如果连接关闭了需要释放资源 
  codec.Close() 
} 

执行远程方法并返回响应

Server 的 call 函数就是通过 Func.Call 反射调用对应 RPC 过程的方法,它还会调用 send Response 将返回值发送给 RPC 客户端,代码如下:

func (s service) call(server Server, sending sync.Mutex, mtype methodType, req Request, argv, replyv reflect.Value, codec ServerCodec) {
    
    
  function := mtype.method.Func 
  // 这里是真正调用rpc方法的地方 
  returnValues := function.Call([]reflect.Value{
    
    s.rcvr, argv, replyv}) 
  errInter := returnValues[0].Interface() 
  errmsg := "" 
  // 处理返回请求了 
  server.sendResponse(sending, req, replyv.Interface(), codec, errmsg) 
  server.freeRequest(req) 
} 

客户端发送 RPC 请求原理

无论是同步调用还是异步调用,每次 RPC 请求都会生成一个 Call 对象,并使用 seq 作为 key 保存在 map 中,服务端返回响应值时再根据响应值中的 seq 从 map 中取出 Call,进行相应处理。客户端发起 RPC 调用的过程大致如下图所示。
在这里插入图片描述
下面我们将依次讲解同步调用和异步调用、请求参数编码和接收服务器响应三个部分的具体实现。

同步调用和异步调用

本课时的案例展示了 Go 原生 RPC 的客户端支持同步和异步两种调用,下面我们来介绍一下这两种调用的函数以及调用的数据结构。

func (client Client) Call(serviceMethod string, args interface{
    
    }, reply interface{
    
    }) error {
    
    
 call := <-client.Go(serviceMethod, args, reply, make(chan Call, 1)).Done
 return call.Error
}

Call 方法直接调用了 Go 方法,而 Go 方法则是先创建并初始化了 Call 对象,记录下此次调用的方法、参数和返回值,并生成 DoneChannel;然后调用 Client 的 send 方法进行真正的请求发送处理,代码如下:

// 异步调用实现
func (client Client) Go(serviceMethod string, args interface{
    
    }, reply interface{
    
    }, done chan Call) Call {
    
    
 // 初始化 Call 
 call := new(Call)
 call.ServiceMethod = serviceMethod
 call.Args = args
 call.Reply = reply
 if done == nil {
    
    
   done = make(chan Call, 10) // buffered.
 } else {
    
    
   if cap(done) == 0 {
    
    
     log.Panic("rpc: done channel is unbuffered")
   }
 }
 call.Done = done
  // 调用 Client 的 send 方法
 client.send(call)
 return call
}
type Call struct {
    
    
  ServiceMethod string   // 服务名及方法名 格式:服务.方法
  Args     interface{
    
    } // 函数的请求参数 (struct).
  Reply     interface{
    
    } // 函数的响应参数 (struct).
  Error     error    // 方法完成后 error的状态.
  Done     chan Call // 
}

请求参数编码

Client 的 send 函数首先会判断客户端实例的状态,如果处于关闭状态,则直接返回结果;否则会生成唯一的 seq 值,将 Call 保存到客户端的哈希表 pending 中,然后调用客户端编码器的WriteRequest 来编码请求并发送,代码如下:

func (client Client) send(call Call) {
    
    
  // ....
  //生成seq,每次调用均生成唯一的seq,在服务端返回结果后会通过该值进行匹配
  seq := client.seq
  client.seq++
  client.pending[seq] = call
  client.mutex.Unlock()

  // 请求并发送请求
  client.request.Seq = seq
  client.request.ServiceMethod = call.ServiceMethod
  err := client.codec.WriteRequest(&client.request, call.Args)
  if err != nil {
    
    
    //发送请求错误时,将map中call对象删除.
    client.mutex.Lock()
    call = client.pending[seq]
    delete(client.pending, seq)
    client.mutex.Unlock()
  }
}

接收返回值

接下来我们来看一下客户端是如何接收并处理服务端返回值的。客户端的 input 函数接收服务端返回的响应值,它进行无限 for 循环,不断调用 codec 也就是 gobClientCodecd 的 ReadResponseHeader 函数,然后根据其返回数据中的 seq 来判断是否是本客户端发出请求的响应值。如果是,则获取对应的 Call 对象,并将其从 pending 哈希表中删除,继续调用 codec 的 ReadReponseBody 方法获取返回值 Reply 对象,并调用 Call 对象的 done 方法,代码如下:

func (client Client) input() {
    
    
  var err error
  var response Response
  for err == nil {
    
    
    response = Response{
    
    }
    //通过response中的 Seq获取call对象
    seq := response.Seq
    client.mutex.Lock()
    call := client.pending[seq]
    delete(client.pending, seq)
    client.mutex.Unlock()

    switch {
    
    
    case call == nil:
    case response.Error != "":
      //上述两个case,一个处理call为nil,另外处理服务端返回的错误,直接将错误返回
    default:
      //通过编码器,将Resonse的body部分解码成reply对象.
      err = client.codec.ReadResponseBody(call.Reply)
      if err != nil {
    
    
        call.Error = errors.New("reading body " + err.Error())
      }
      call.done()
    }
  }
}
上述代码中,gobClientCodecd 的 ReadResponseHeader、ReadReponseBody 方法和上文中的 WriteRequest 类似,这里不做赘述。Call 对象的 done 方法则通过 Call 的 DoneChannel,将获得返回值的结果通知到调用层,代码如下:

func (call *Call) done() {
    
    
 select {
    
    
 case call.Done <- call:
   // ok
 default:
   if debugLog {
    
    
     log.Println("rpc: discarding Call reply due to insufficient Done chan capacity")
   }
 }
}

客户端接收到 RPC 请求的响应后会进行其他业务逻辑操作,RPC 框架则会对进行 RPC 请求所需要的资源进行回收,下次进行 RPC 请求时则需要再次建立相应的结构体并获取对应的资源。

Go 语言原生 RPC 算是个基础版本的 RPC 框架,代码精简,可扩展性高,但是只实现了 RPC 最基本的网络通信,而超时熔断、链接管理(保活与重连)、服务注册发现等功能还是欠缺的。因此还是达不到生产环境“开箱即用”的水准,不过 GitHub 就有一个基于 RPC 的功能增强版本——rpcx,支持了大部分主流 RPC 的特性。

虽然目前官方已经宣布不再添加新功能,并推荐使用 gRPC,但是作为 Go 标准库中的 RPC 框架,还是有很多地方值得我们借鉴和学习,比如注册服务时如何保存反射信息等。

猜你喜欢

转载自blog.csdn.net/weixin_45843419/article/details/125382536