gRPC Go! Go! Go!| 🏆 技术专题第二期征文

5,910 阅读11分钟

什么是RPC (Remote Procedure Call) ?

简单地说,RPC 就是像调用本地服务一样调用远程服务。

它是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的协议。也就是说两台服务器A,B,一个应用部署在A服务器上,想要调用B服务器上应用提供的方法,由于不在一个内存空间,不能直接调用,需要通过网络来表达调用的语义和传达调用的数据。

RPC协议假定某些传输协议的存在,如TCP或UDP,为通信程序之间携带信息数据。在OSI网络通信模型中,RPC跨越了传输层和应用层。RPC使得开发包括网络分布式多程序在内的应用程序更加容易。现在业界有很多开源的优秀 RPC 框架,例如 Spring Cloud、Dubbo、Thrift、gRPC等。

RPC 其实并不是什么新鲜的概念,RPC 这个概念术语在上世纪 80 年代由 Bruce Jay Nelson 在他的论文 Implementing Remote Procedure Calls 提出。

RPC 工作原理

  1. Client像调用本地服务似的调用远程服务;
  2. Client stub接收到调用后,将方法、参数序列化
  3. 客户端通过sockets将消息发送到服务端
  4. Server stub 收到消息后进行解码(将消息对象反序列化)
  5. Server stub 根据解码结果调用本地的服务
  6. 本地服务执行(对于服务端来说是本地执行)并将结果返回给Server stub
  7. Server stub将返回结果打包成消息(将结果消息对象序列化)
  8. 服务端通过sockets,或者其他方式将消息发送到客户端
  9. Client stub接收到结果消息,并进行解码(将结果消息反序列化)
  10. 客户端得到最终结果

RPC 和 HTTP 有什么区别?

从广义上讲,HTTP本质上就是RPC构想的一种实现,HTTP就是RPC思想的一种体现。

RPC 的意义在于解决分布式系统中,服务直接调用的问题,远程调用时能够像本地调用一样的简单,就像空气和水一样。

从狭义上讲,现在的RPC框架不在局限于服务间的调用这么简单,还附带了更多的服务治理,服务发现,熔断降级,网关等功能。

为什么要使用 RPC 而不是HTTP ?

HTTP协议在我们的网络模型分层中属于应用层,在第七层。

我们通用的 HTTP 1.X 协议,一个POST 请求的格式如下

HTTP/1.0 200 OK 
Content-Type: text/plain
Content-Length: 137582
Expires: Thu, 05 Dec 1997 16:00:00 GMT
Last-Modified: Wed, 5 August 1996 15:55:28 GMT
Server: Apache 0.84

{
	"name": "hello"
}

整个报文的数据中,Header的键值对占用了70%以上的字节数。这样极大的浪费了传输资源。 当使用RPC的特有的二进制协议进行传输时,使用二进制编码,基于私有协议进行传输,可以大大的提高传输的效率,这在大规模的网络服务中会带来一些性能上的提升。

gRPC

目前流行的RPC框架有很多,它们各有特色,这里我们选择gRPC来做实践。

gRPC是一个高性能、通用的开源RPC框架,其由Google开发,基于HTTP/2协议标准而设计,基于Protobuf(Protocol Buffers)序列化协议开发,且支持众多开发语言。

支持C++、Java、Go、Python、Ruby、C#、Node.js、Android Java、Objective-C、PHP等编程语言。

Nginx 在1.13.10 版本支持了gRPC的代理。

gRPC 使用protubuf作为二进制编码

Protocol Buffers是 Google 开发,平台中立、可扩展的,用于序列化结构化数据,像 XML 一样,但更小、更快、更简单。你只需要定义一次你想要的数据结构,然后你就可以使用特殊生成的源代码来轻松地从各种数据流和各种语言中写入和读取你的结构化数据。

Protobuf 性能和效率大幅度优于 JSON、XML 等其他的结构化数据格式。Protobuf 是以二进制方式存储的,占用空间小,但也带来了可读性差的缺点。

Protobuf 在 .proto 定义需要处理的结构化数据,可以通过 protoc 工具,将 .proto 文件转换为 C、C++、Golang、Java、Python 等多种语言的代码,兼容性好,易于使用。

1. 安装protubuf编译器

进入protobuf的Release页面 github.com/protocolbuf…

下载对应平台的最新压缩包,这里以Mac为例,下载 github.com/protocolbuf…

解压缩到你设定好的目录下,然后更新PATH系统变量,或者确保protoc放在了PATH目录中。

2. 安装Go protoc编译器插件

go get -u github.com/golang/protobuf/protoc-gen-go

3. 安装Go gPRC包

go get google.golang.org/grpc

Go gRPC 实践并与HTTP对比

以上代码全部放在我的Github仓库中,感兴趣的小伙伴可以试验一番。

github.com/3inchtime/p…

本次的Demo设计如下,使用HTTP和gRPC同时访问后端Server,来对数据库增删改查。

整体设计为一个视频网站,对数据进行新增以及查询。

有两个服务:

  • domain 对比暴露HTTP接口

  • backend 后台服务,domain使用gRPC或者HTTP与backend通信

HTTP的部分我们使用gin来作为框架。

我们定义两个接口:

  • get_video 获取视频信息
  • create_video 创建视频信息

1. 准备

数据结构如下:

含义字段
IDid
标题title
简介note
封面图pic
视频地址video

首先上建表语句

CREATE TABLE `video` (
  `id` char(64) NOT NULL,
  `title` varchar(256) NOT NULL,
  `note` varchar(256) NOT NULL,
  `pic_path` char(64) NOT NULL,
  `video_path` char(64) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `video_id_uindex` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

创建项目目录如下

|pilipili
|
├─backend
│  ├─dao
│  ├─internal
│  │  ├─server
│  │  └─service
│  └─model
├─domain
│  └─internal
│      ├─server
│      └─service
└─proto

包管理建议使用go mod,再此就不赘述了。

2. 创建proto文件

syntax = "proto3";

package proto;

service Pilipili {
  rpc RpcGetVideoInfo(GetVideoRequest) returns (VideoInfo) {}
  rpc RpcCreateVideoInfo(VideoInfo) returns (CreateVideoReplay) {}
}

message GetVideoRequest {
  string id = 1;
}

message CreateVideoReplay {
  string status = 1;
}

message VideoInfo {
  string id = 1;
  string title = 2;
  string note = 3;
  string pic = 4;
  string video = 5;
}
  • protobuf 有2个版本,语法不同,默认版本是 proto2,如果需要 proto3,则需要在非空非注释第一行使用 syntax = "proto3" 标明版本。现在推荐使用proto3版本。

  • 我们用了三个结构体定义了数据:GetVideoRequest用户请求视频详情,其中id为请求内容。CreateVideoReplay则是创建视频信息时的响应。VideoInfo就是我们视频信息了。

  • service Pilipili 定义了我们的protobuf提供服务的两个接口,包括RpcGetVideoInfo,RpcCreateVideoInfo

  • RpcGetVideoInfo 方法发送一个 GetVideoRequest 得到 VideoInfo

  • RpcCreateVideoInfo 方法发送一个 VideoInfo 得到 CreateVideoReplay

3. 创建Go protobuf 文件

 protoc -I . --go_out=plugins=grpc:. ./pilipili.proto

这样,在pilipili.proto同目录下就生成了pilipili.pb.go文件,这个文件中包含了我们gRPC中所有需要的方法。

4. 服务端代码开发

4.1 数据库连接

首先创建Go的数据库连接

backend/dao/dao.go

package dao

import (
	"database/sql"
	_ "github.com/go-sql-driver/mysql"
)

type Dao struct {
	DB *sql.DB
}

func NewDao() *Dao {
	return &Dao{
		DB: NewDB(),
	}
}

func NewDB() *sql.DB {
	Mysql, err := sql.Open("mysql", "root:123456@tcp(localhost:3306)/pilipili?charset=utf8mb4")
	if err != nil {
		panic(err)
	}
	Mysql.SetMaxOpenConns(10)
	return Mysql
}

Go 操作MySQL的部分我们就不在此赘述。

4.2 数据库操作

backend/model/model.go

package model

type Video struct {
	ID        string `json:"id"`
	Title     string `json:"title"`
	Note      string `json:"note"`
	PicPath   string `json:"pic_path"`
	VideoPath string `json:"video_path"`
}

创建Video结构体。

backend/dao/info.go

package dao

import (
	"database/sql"
	"github.com/google/uuid"
	"github.com/sirupsen/logrus"
	"pilipili/backend/model"

	_ "github.com/go-sql-driver/mysql"
)

# 根据id查询视频数据
func (d *Dao) GetVideoInfo(id string) *model.Video {
	querySQL, err := d.DB.Prepare("SELECT title, note, pic_path, video_path FROM video WHERE id = ?")
	if err != nil {
		logrus.Errorf("Prepare Select SQL Error: %s", err.Error())
		return nil
	}

	defer querySQL.Close()
	v := new(model.Video)
	err = querySQL.QueryRow(id).Scan(&v.Title, &v.Note, &v.PicPath, &v.VideoPath)
	if err != nil && err != sql.ErrNoRows {
		logrus.Errorf("Query Video Error: %s", err.Error())
	}
	return v
}

# 创建视频数据,id使用UUID
func (d *Dao) CreateNewVideo(v *model.Video) {
	id := UUID()
	title := v.Title
	note := v.Note
	picPath := v.PicPath
	videoPath := v.VideoPath

	insertSql, err := d.DB.Prepare("INSERT INTO video (id, title, note, pic_path, video_path) VALUES (?, ?, ?, ?, ?)")
	if err != nil {
		logrus.Errorf("Prepare Insert SQL Error: %s", err.Error())
	}

	_, err = insertSql.Exec(id, title, note, picPath, videoPath)
	if err != nil {
		logrus.Errorf("Insert Video Info SQL Error: %s", err.Error())
	}
	defer insertSql.Close()
	return
}

func UUID() string {
	id, err := uuid.NewUUID()
	if err != nil {
		logrus.Infof("UUID Error: %s", err.Error())
	}
	return id.String()
}

这样我们首先封装好了我们数据库的操作代码,接下来我们开始写gRPC的部分。

4.3 gRPC 服务端

backend/internal/service/grpc.go

package service

import (
	"context"
	"pilipili/backend/model"
	pb "pilipili/proto"
)

func (s *Service) RpcGetVideoInfo(ctx context.Context, request *pb.GetVideoRequest) (*pb.VideoInfo, error) {
	id := request.Id
	v := s.dao.GetVideoInfo(id)

	# 直接使用proto生成的go代码,里面带有所有我们定义好的数据结构
	resp := new(pb.VideoInfo)

	resp.Id = id
	resp.Title = v.Title
	resp.Note = v.Note
	resp.Pic = v.PicPath
	resp.Video = v.VideoPath

	return resp, nil
}

func (s *Service) RpcCreateVideoInfo(ctx context.Context, info *pb.VideoInfo) (*pb.CreateVideoReplay, error) {
	title := info.Title
	note := info.Note
	pic := info.Pic
	video := info.Video

	v := new(model.Video)
	v.Title = title
	v.Note = note
	v.PicPath = pic
	v.VideoPath = video
    
	s.dao.CreateNewVideo(v)
    
	# 直接使用proto生成的go代码,里面带有所有我们定义好的数据结构
	resp := new(pb.CreateVideoReplay)
	resp.Status = "OK"
	return resp, nil
}

这里引入了我们使用proto生成的Go代码,就可以直接使用我们定义好的所有数据结构了。

RpcGetVideoInfo 接收一个 GetVideoRequest,返回一个VideoInfo

RpcCreateVideoInfo 接收一个 VideoInfo,返回一个CreateVideoReplay

4.4 HTTP 服务端

backend/internal/service/http.go

package service

import (
	"github.com/gin-gonic/gin"
	"net/http"
	"pilipili/backend/model"
)

func (s *Service) InitRoutes() {
	s.Router.GET("/video", s.HttpGetVideoInfo)
	s.Router.POST("/video", s.HttpCreateVideo)
}

func (s *Service) HttpGetVideoInfo(ctx *gin.Context) {
	id := ctx.Query("id")
	v := s.dao.GetVideoInfo(id)
	ctx.JSON(http.StatusOK, gin.H{
		"id":    id,
		"title": v.Title,
		"note":  v.Note,
		"pic":   v.PicPath,
		"video": v.VideoPath,
	})
}

func (s *Service) HttpCreateVideo(ctx *gin.Context) {
	title := ctx.PostForm("title")
	note := ctx.PostForm("note")
	pic := ctx.PostForm("pic")
	video := ctx.PostForm("video")

	v := new(model.Video)
	v.Title = title
	v.Note = note
	v.PicPath = pic
	v.VideoPath = video

	s.dao.CreateNewVideo(v)

	ctx.String(http.StatusOK, "OK")
}

这里我为HTTP服务定义了一个Restful风格接口:

GET /video 获取视频信息 POST /video 创建视频信息

4.5 构建后端服务

现在我们将gRPC以及HTTP服务整合成为一个服务。

backend/internal/service/service.go

package service

import (
	"github.com/gin-gonic/gin"
	"google.golang.org/grpc"
	"pilipili/backend/dao"
)

type Service struct {
	dao    *dao.Dao
	Router *gin.Engine
	RPC    *grpc.Server
}

func NewGRPCServer() *grpc.Server {
	s := grpc.NewServer()
	return s
}

func NewService() *Service {
	s := &Service{
		dao:    dao.NewDao(),
		Router: gin.Default(),
		RPC:    NewGRPCServer(),
	}
	return s
}

这样我们就整合了HTTP以及gRPC在我们的Service结构体中了。

接下来是初始化服务的代码

backend/internal/server/server.go

package server

import (
	"github.com/sirupsen/logrus"
	"net"
	"net/http"
	"pilipili/backend/internal/service"
	pb "pilipili/proto"
)

func NewHttpServer(s *service.Service) {
	s.InitRoutes()
	server := http.Server{
		Addr:    ":23333",
		Handler: s.Router,
	}
	go func() {
		if err := server.ListenAndServe(); err != nil {
			logrus.Errorf("HTTP Server Error: %s", err.Error())
		}
	}()
}

func NewGRPCServer(s *service.Service) {
	Address := "127.0.0.1:23332"
	listen, err := net.Listen("tcp", Address)
	if err != nil {
		logrus.Errorf("Listen Error: %s", err.Error())
		panic(err)
	}
    
    # 这里也是proto替我们生成好的方法,监听了我们的gRPC服务
	pb.RegisterPilipiliServer(s.RPC, s)
	s.RPC.Serve(listen)
}

4.6 服务端 main.go

backend/main.go

package main

import (
	"os"
	"os/signal"
	"pilipili/backend/internal/server"
	"pilipili/backend/internal/service"
	"syscall"
)

func main() {
	s := service.NewService()
	server.NewHttpServer(s)
	server.NewGRPCServer(s)
	c := make(chan os.Signal, 1)
	signal.Notify(c, syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT)
	for {
		switch <-c {
		case syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT:
			return
		case syscall.SIGHUP:
		}
	}
}

5. 连接端开发

5.1 HTTP 请求backend代码

domain/internal/service/http.go

package service

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"github.com/sirupsen/logrus"
	"io/ioutil"
	"net/http"
	"net/url"
)

const address = "http://0.0.0.0:23333/video"

func (s *Service) HttpGetVideoInfo(ctx *gin.Context) {
	id := ctx.Query("id")
	resp, err := http.Get(address + fmt.Sprintf("?id=%s", id))
	if err != nil {
		logrus.Errorf("HTTP Get Video Error: %s", err.Error())
	}
	defer resp.Body.Close()

	if resp.StatusCode == 200 {
		body, err := ioutil.ReadAll(resp.Body)
		if err != nil {
		}
		ctx.String(http.StatusOK, string(body))
	} else {
		ctx.String(http.StatusInternalServerError, "Failed")
	}
}

func (s *Service) HttpCreateVideo(ctx *gin.Context) {
	title := ctx.PostForm("title")
	note := ctx.PostForm("note")
	pic := ctx.PostForm("pic")
	video := ctx.PostForm("video")

	resp, err := http.PostForm(address, url.Values{
		"title": {title},
		"note":  {note},
		"pic":   {pic},
		"video": {video},
	})
	if err != nil {
		logrus.Errorf("Post Form Error: %s", err.Error())
	}

	defer resp.Body.Close()

	if resp.StatusCode == 200 {
		ctx.String(http.StatusOK, "OK")
	} else {
		ctx.String(http.StatusInternalServerError, "Failed")
	}
}

以上全部的HTTP部分都是使用了gin框架,详细的教程可以看gin的相关文档。

5.2 gRPC 请求backend代码

domain/internal/service/grpc.go


package service

import (
	"context"
	"github.com/gin-gonic/gin"
	"github.com/sirupsen/logrus"
	"net/http"
	pb "pilipili/proto"
)

func (s *Service) GRPCGetVideoInfo(ctx *gin.Context) {
	rpcRequest := new(pb.GetVideoRequest)
	rpcRequest.Id = ctx.Query("id")
	v, err := s.RPC.RpcGetVideoInfo(context.Background(), rpcRequest)
	if err != nil {
		logrus.Errorf("RPC Server Error: %s", err.Error())
	}
	if v != nil {
		ctx.JSON(http.StatusOK, gin.H{"id": v.Id, "title": v.Title, "note": v.Note, "pic": v.Pic, "video": v.Video})
	} else {
		ctx.String(http.StatusInternalServerError, "Failed")
	}
}


func (s *Service) GRPCCreateVideo(ctx *gin.Context) {
	rpcRequest := new(pb.VideoInfo)
	rpcRequest.Title = ctx.PostForm("title")
	rpcRequest.Note = ctx.PostForm("note")
	rpcRequest.Pic = ctx.PostForm("pic")
	rpcRequest.Video = ctx.PostForm("video")

	v, err := s.RPC.RpcCreateVideoInfo(context.Background(), rpcRequest)
	if err != nil {
		logrus.Errorf("RPC Server Error: %s", err.Error())
	}
	if v != nil {
		ctx.String(http.StatusOK, v.Status)
	} else {
		ctx.String(http.StatusInternalServerError, "Failed")
	}
}

以上的代码就是使用gRPC访问backend的部分了,可以看到这里就实现了PRC的理念,像调用本地方法一样,调用了远程方法。

5.3 HTTP与gRPC代码的对比

在上面的代码中我们可以看到明显的区别。

我们传动的HTTP请求需要指定GET或者POST方法......等等一系列操作。

resp, err := http.PostForm(address, url.Values{
		"title": {title},
		"note":  {note},
		"pic":   {pic},
		"video": {video},
	})

而gRPC则像调用本地方法啊一样,就调用了远程的方法,这无疑更加的优雅。

	v, err := s.RPC.RpcCreateVideoInfo(context.Background(), rpcRequest)

5.4 连接端路由

domain/internal/servide/routes.go

package service

func (s *Service) InitRoutes() {
	httpRoutes := s.Router.Group("/http")
	{
		httpRoutes.GET("/get_video", s.HttpGetVideoInfo)
		httpRoutes.POST("/create_video", s.HttpCreateVideo)
	}
	grpcRoutes := s.Router.Group("/grpc")
	{
		grpcRoutes.GET("/get_video", s.GRPCGetVideoInfo)
		grpcRoutes.POST("/create_video", s.GRPCCreateVideo)
	}
}

这里使用了gin 的HTTP路由组的写法,更加的直观。

这里为HTTP请求以及gRPC做了区分。

5.5 连接端服务

domain/internal/service/service.go


package service

import (
	"github.com/gin-gonic/gin"
	"github.com/sirupsen/logrus"
	"google.golang.org/grpc"
	"google.golang.org/grpc/grpclog"
	pb "pilipili/proto"
)

type Service struct {
	Router *gin.Engine
	RPC    pb.PilipiliClient
}

func NewService() *Service {
	s := &Service{
		Router: gin.Default(),
		RPC:    NewGRPC(),
	}
	return s
}

func NewGRPC() pb.PilipiliClient {
	Address := "127.0.0.1:23332"
	conn, err := grpc.Dial(Address, grpc.WithInsecure())

	if err != nil {
		grpclog.Fatal(err)
		logrus.Errorf("Start GRPC Error: %s", err.Error())
	}

	c := pb.NewPilipiliClient(conn)
	return c
}

5.5 连接端初始化服务

domain/internal/server/server.go


package service

import (
	"github.com/gin-gonic/gin"
	"github.com/sirupsen/logrus"
	"google.golang.org/grpc"
	"google.golang.org/grpc/grpclog"
	pb "pilipili/proto"
)

type Service struct {
	Router *gin.Engine
	RPC    pb.PilipiliClient
}

func NewService() *Service {
	s := &Service{
		Router: gin.Default(),
		RPC:    NewGRPC(),
	}
	return s
}

# 创建gRPC连接
func NewGRPC() pb.PilipiliClient {
	Address := "127.0.0.1:23332"
	conn, err := grpc.Dial(Address, grpc.WithInsecure())

	if err != nil {
		grpclog.Fatal(err)
		logrus.Errorf("Start GRPC Error: %s", err.Error())
	}

	c := pb.NewPilipiliClient(conn)
	return c
}

5.6 连接端 main.go


package main

import (
	"os"
	"os/signal"
	"pilipili/domain/internal/server"
	"pilipili/domain/internal/service"
	"syscall"
)

func main() {
	s := service.NewService()
	server.NewServer(s)
	c := make(chan os.Signal, 1)
	signal.Notify(c, syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT)
	for {
		switch <-c {
		case syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT:
			return
		case syscall.SIGHUP:
		}
	}
}

6. 测试

现在我们完成了服务端与连接端的代码开发。

分别启动服务端与连接端的服务。

我们的连接端暴露出了4个HTTP接口:

/http/get_video

/http/create_video

/grpc/get_video

/grpc/create_video

http使用了HTTP请求后端的服务

grpc使用grpc请求后端的服务

到这里大家就可以使用Postman来进行测试了。

7. 结语

本篇教程还是比较基础的,带大家基本的开发了一个比较实用的gRPC服务,比起简单的Hello World还是更有实际用处的。

感谢大家阅读我的文档,如有错误请大家指出。

以上代码全部放在我的Github仓库中,感兴趣的小伙伴可以试验一番。

github.com/3inchtime/p…

再次感谢大家~~~

Go ~ Go ~ Go !

🏆 技术专题第二期 | 我与 Go 的那些事......