Go

使用golang进行rpc开发之一使用原生rpc开发

很早之前也了解过一些rpc, 但是心理始终接受不了这种东西,觉得http 用的好好的,为什么要那么麻烦的使用rpc? 且还要定义什么proto 文件 ,后来看了一篇文章 ,既然业内都在使用并且都快成为一种行业标准了,必然有它的优势。 以下是…

很早之前也了解过一些rpc, 但是心理始终接受不了这种东西,觉得http 用的好好的,为什么要那么麻烦的使用rpc? 且还要定义什么proto 文件 ,后来看了一篇文章 ,既然业内都在使用并且都快成为一种行业标准了,必然有它的优势。 以下是文章原文

既然有HTTP协议,为什么还要有RPC

一、go中原生的rpc开发

go 标准库的 net\rpc 对rpc原生的支持,我们先来看一下使用go标准库是如何开发rpc应用的。 服务端开发主要分为以下四步

  1. 注册rpc服务
  2. 设置端口监听
  3. 等待连接
  4. 服务调用
go
package main

import (
	"fmt"
	"net"
	"net/rpc"
)

type Hello struct{}

// 绑定类方法
func (h Hello) SayHi(req string, res *string) error {
	s := "hello " + req
	*res = s
	return nil
}

func main() {
	// 1. 注册rpc 服务
	if err := rpc.RegisterName("hello", &Hello{}); err != nil {
		fmt.Println(err)
		return
	}
	// 2. 设置监听
	lister, err := net.Listen("tcp", "0.0.0.0:8080")
	if err != nil {
		fmt.Println(err)
		return
	}
	// 设置关闭
	defer lister.Close()
	for {
		fmt.Println("等待连接。。。。")
		// 3. 建立连接
		conn, err := lister.Accept()
		if err != nil {
			fmt.Println(err)
		}
		// 4. 绑定服务
		rpc.ServeConn(conn)
	}

}

客户端主要二个步骤

  1. 与rpc 服务器建立连接
  2. 调用远程方法
go
package main

import (
	"fmt"
	"net/rpc"
)

func main() {
	// 1. 与rpc 服务器建立连接
	rclient, err := rpc.Dial("tcp", "127.0.0.1:8080")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer rclient.Close()
	//2. 调用远程方法
	var rep string
	err = rclient.Call("hello.SayHi", "yangyanxing", &rep)
	if err != nil {
		fmt.Println("call fail:", err)
		return
	}
	fmt.Println(rep)
}

考虑以下几个问题

  1. 如果调用了不存在的方法会如何?
  2. 如果远程调用方法操作时间比较长,是否会阻塞后来的调用?

对于第一个问题,我们改下客户端代码,让其调用远程的hello.SomeM方法,此时我们并未在服务端中为hello 结构体定义 SomeM 方法

go
err = rclient.Call("hello.SomeM", "yangyanxing", &rep)

此时,客户端调用远程方法的返回值err就不再为nil 了,而是 rpc: can't find service hello.SomeM了。

对于第二个问题,我们再改一下客户端的代码,启两个协程,同时进行远程调用, 在服务端,我们人为的让结构体方法停顿3秒钟,先修改下服务端代码

go
// 绑定类方法
func (h Hello) SayHi(req string, res *string) error {
	s := "hello " + req
	time.Sleep(3 * time.Second)
	*res = s
	return nil
}

然后再修改下客户端调用的代码

go
package main

import (
	"fmt"
	"net/rpc"
	"sync"
	"time"
)

var wg sync.WaitGroup

func rcall(r *rpc.Client, num int8) {
	defer wg.Done()
	fmt.Printf("Num:%d start run....\n", num)
	start := time.Now()
	var rep string
	err := r.Call("hello.SayHi", "yangyanxing", &rep)
	if err != nil {
		fmt.Println(err)
	}
	fmt.Println(rep, num)
    // 打印耗时
	used := time.Since(start).Seconds()
	fmt.Printf("used %f seconds\n", used)
}

func main() {
	// 1. 与rpc 服务器建立连接
	rclient, err := rpc.Dial("tcp", "127.0.0.1:8080")

	if err != nil {
		fmt.Println(err)
		return
	}
	defer rclient.Close()
	wg.Add(2)
    // 启两个协程同时请求
	go rcall(rclient, 1)
	go rcall(rclient, 2)
	wg.Wait()
	fmt.Println("over....")
}

我们将远程调用单独定义一个函数,并且在函数中打印调用耗时,如果在服务端处有阻塞,也就是服务端只能处理一个请求,那么这两个协程肯定有一个协程的耗时大概要6秒,但如果两个协程都耗时3秒左右,则说明服务端是并行的处理客户端请求的。 运行客户端程序,得到以下输出

shell
Num:2 start run....
Num:1 start run....
hello yangyanxing 1
used 3.004542 seconds
hello yangyanxing 2
used 3.004675 seconds
over....

这说明,rpc服务端不会阻塞客户端的调用, 也可以理解为,服务端会启单独的协程来处理请求,大概看了下源码,关键的函数如下

go
func (server *Server) ServeCodec(codec ServerCodec) {
	sending := new(sync.Mutex)
	wg := new(sync.WaitGroup)
	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
		}
		wg.Add(1)
		go service.call(server, sending, wg, mtype, req, argv, replyv, codec)
	}
	// We've seen that there are no more requests.
	// Wait for responses to be sent before closing codec.
	wg.Wait()
	codec.Close()
}

可以看到这里是单独的启了一个协程来处理请求。

几个注意的问题:

  1. 服务端在定义rpc结构体方法时,响应数据要使用指针变量

在服务端定义结构体方法时

go
type Hello struct{}

// 绑定类方法
func (h Hello) SayHi(req string, res *string) error {
	s := "hello " + req
	// time.Sleep(3 * time.Second)
	*res = s
	return errors.New("test error....")
}

SayHi 方法,第二个参数,也就是要返回的内容,这里必须使用指针类型,否则客户端得不到该值。甚至在运行时就会发生panic,rpc.Register: type hello has no exported methods of suitable type

  1. 服务端也可以直接返回错误类型,这样客户端调用远程方法就会得到这个错误。

如果服务端在处理rpc请求的时候,在遇到错误时,可以将错误直接返回

go
unc (h Hello) SayHi(req string, res *string) error {
	s := "hello " + req
	// time.Sleep(3 * time.Second)
	*res = s
	return errors.New("test error...")
}

这时,即使在函数中已经将修改了res的值,但是客户端也是得不到这个值的,err := r.Call("hello.SayHi", "yangyanxing", &rep),这里的err 为 test error...., 但是 rep 依然是个空。

  1. 结构体的rpc方法要是首字母大写的,也就是这些方法要是可以导出的

二、请求与响应使用结构体

上面的例子很简单,我们在rpc函数中,请求为字符串,响应为字符串指针,但是在更多的时候,我们会传入内容更加丰富的结构体,响应也为结构体指针。

我们先来修改一下服务端代码

go
package main

import (
	"fmt"
	"net"
	"net/rpc"
)

type Hello struct{}

type Person struct {
	Name string
	Age  int
}

type Res struct {
	Msg  string
	Code int
}

// 绑定类方法
func (h Hello) SayHi(req Person, res *Res) error {
	fmt.Println(req.Name, req.Age)
	*res = Res{"success", 200}
	return nil
}

func main() {
	// 1. 注册rpc 服务
	if err := rpc.RegisterName("hello", Hello{}); err != nil {
		fmt.Println(err)
		return
	}
	// 2. 设置监听
	lister, err := net.Listen("tcp", "0.0.0.0:8080")
	if err != nil {
		fmt.Println(err)
		return
	}
	// 设置关闭
	defer lister.Close()
	for {
		fmt.Println("等待连接。。。。")
		// 3. 建立连接
		conn, err := lister.Accept()
		if err != nil {
			fmt.Println(err)
		}
		// 4. 绑定服务
		rpc.ServeConn(conn)
	}

}

先定义好两个结构体,一个是Person, 这个是作为请求类型的,一个是Res,这个是作为响应的类型,之后再修改SayHi 方法的参数类型。

之后再修改客户端的远程调用函数。

go
package main

import (
	"fmt"
	"net/rpc"
)

type Person struct {
	Name string
	Age  int
}

type Res struct {
	Msg  string
	Code int
}

func main() {
	// 1. 与rpc 服务器建立连接
	rclient, err := rpc.Dial("tcp", "127.0.0.1:8080")

	if err != nil {
		fmt.Println(err)
		return
	}
	defer rclient.Close()
	// 2. 调用远程方法
	var rep Res
	req := Person{"yangyanxing", 18}

	err = rclient.Call("hello.SayHi", req, &rep)
	if err != nil {
		fmt.Println("call fail:", err)
		return
	}
	fmt.Printf("%#v \n", rep) //main.Res{Msg:"success", Code:200}
	fmt.Println("over....")
}

这里定义和服务端相同的的两个结构体Person和Res,调用的时候,也是按照服务端的参数类型进行调用,我们可以得到以下的输出:

shell
main.Res{Msg:"success", Code:200}

这个也正是由服务端返回的。 我们再考虑以下几个问题

  1. 如果客户端所传的参数类型和服务端不一致会如何?

这里的不一致我想到又可以分为以下几种情况 1.1 类型名称一致,但是里的属性不一致 1.2 类型名称不一致,但是里面的属性是一致的 1.3 类型名称与属性都不一致 1.4 属性名称一致,但是属性的类型不一致

我们依次来验证一下。 对于第一种情况,类型名称一致,但是结构体里的属性不一致的情况,修改一下客户端里的关于Person的定义

go
type Person struct {
	Name string
	Age  int
	Sex  string
}

func main(){
    ......
    req := Person{"yangyanxing", 18, "男"}
    err = rclient.Call("hello.SayHi", req, &rep)
    ......
}

这里我新添加了一个Sex的属性,这时也可以正常的收到服务端的响应,并且服务端中Person.Name 和Person.Age 均获取到了客户端发过来的值。这也说明添加一个属性并不会影响值的传递。 如果将Age改名(或者说去掉) 会如何呢?

go
type Person struct {
	Name string
	Ages  int
	Sex  string
}

此时依然可以收到服务端发来的success的响应,但是服务端却只能收到Name值,并不会收到Age值,main.Person{Name:"yangyanxing", Age:0} ,Age 为默认值 0 如果再将Name值也去掉呢?

go
type Person struct {
	Names string
	Ages  int
	Sex  string
}

这时就得不到服务端的正常响应了,而是得到一个错误

shell
call fail: gob: type mismatch: no fields matched compiling decoder for Person

没有一个 field 匹配上! 这也就说明,在客户端发送rpc请求的时候,如果是结构体参数,则至少需要匹配一具属性值,否则会报错!

对于第二情况,类型名称不一致,但是里面的属性是一致的, 我们将Person结构体随便换个名称

go
type Persons struct {
	Name string
	Age  int
}

此时服务端可以正常的接收请求的参数。

对于第三种情况,都不一致的

go
type Persons struct {
	Names string
	Ages  int
}

这时也会报 call fail: gob: type mismatch: no fields matched compiling decoder for Person

对于第四种情况 ,我们改一个代码, 这里我将Age 改为了 string 类型,服务端是int 类型

go
type Person struct {
	Name string
	Age  string
}

此时会得到 call fail: gob: wrong type (int) for received field Person.Age的错误。

所以我们可以得到以下结论,

  • rpc 客户端请求的结构体和服务端的结构体名称不必完全一致
  • rpc 客户端中的结构体至少有一个要和服务端匹配上
  • rpc 客户端中的结构体,属性名和类型要和服务端一致,否则出现一个不致的就到直接报错
  1. 结构体属性是否需要首字母大写?

我们分别修改 服务端与客户端的Person 为

go
type Person struct {
	name string
	Age  int
}

将Name改为name,此时服务端不会报错,但是name 属性是获取不到的,所以如果想要获取到属性值,需要将属性改为大写。

  1. 结构体是否需要大写?

修改一下服务端的Person 结构体,将其改名为 person, 此时依然会得到一个rpc.Register: type hello has no exported methods of suitable type的错误,这也说明,服务端想要定义rpc方法,需要定义在可导出结构体中。

现在看来一切都是那么完美,但是我们现在考虑一个问题,以上是使用go原生中的net\rpc来实现远程调用, 在这种方式下,如果客户端也是golang,那么会很好的实现调用,既然是远程调用,那么客户端很有可能不是golang的,比如python 或者 php,java等,拿python为例,在python 中是没有结构体的概念,那么如何使用python 来远程调用golang的rpc方法呢?

这里其实要解决的一个问题就是客户端如何将数据发送给服务端,并且映射到服务端中的相应的结构体中?

python 中没有结构体的概念,但是python中有类和字典的概念,那么如何将pytho中的数据类型发送给golang呢?

直接发送个字典或者类肯定是不行的,golang 根本就不认识! 这就牵扯到一个数据的序列化与反序列化的问题,如果我们可以把两种语言通过某种方式进行对相互不认为的数据进行某种转化,从而认对方认识,这样就可以相互交流了,就好比一个中国人和日本人,如果他们都不懂对方的语言,但是他们都懂英语,那么就可以通过说英语从面相互交流,将中文或者日文转化为英文可以理解为序列化,再将英文转化为中文或者日文则为反序列化。 在python和golang中,虽然golang 不懂 python 中的字典,python 也不懂golang中的结构体,但是它们都懂json,所以可以通过json 来相互交流。接下来我们来看一下如何使用json来达到两种不同的语言进行通信的。

杨彦星 后端工程师 · 序语程言主理人

做了八年后端,最近在 AI 行业折腾。喜欢把复杂的事情拆开讲清楚,讨厌「显而易见」这四个字。这里记录我踩过的坑和攒下的经验,愿你少踩一点。

yangyanxing1005@qq.com

查看主页