不基于Gin手撸一个RPC服务

  • Leo
  • 更新于 2024-10-26 23:52
  • 阅读 473

目标实现一个GRPC框架,可以通过grpc-ui来对接口进行访问。也可以使用client来直接调用服务端服务准备(这边以Mac系统举例)安装homebrew(如果没有安装的话)/bin/bash-c"$(curl-fsSLhttps://raw.githubusercontent.c

目标

实现一个GRPC框架,可以通过grpc-ui来对接口进行访问。也可以使用client来直接调用服务端服务

准备(这边以Mac系统举例)

安装homebrew(如果没有安装的话)

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

安装PostgresSql(熟悉Mysql的也可以用mysql代替)

brew install postgresql

psql --version 

安装protobuf 相关组件

brew install protobuf

brew install protoc-gen-go

brew install protoc-gen-go-grpc

protoc --version

protoc-gen-go --version

protoc-gen-go-grpc --version

安装grpc-ui

go install github.com/fullstorydev/grpcui/cmd/grpcui@latest

第一步 定义接口(.proto文件)

初始化一个go工程,我这边命名为school-rpc 创建一个protobuf文件夹,定义一个student.proto文件,该文件后续会由编译脚本进行执行,用于生成pb.go 以及grpc_pb.go 文件

syntax = "proto3";

option go_package = "./protobuf/student";
package school_rpc_service.student;

message StudentListRequest{
  uint32 pageSize = 1;
  uint32 pageNo = 2;
}

message CreateStudentRequest{
  string name = 1;
  uint32 age = 2;
  uint32 gender = 3;
  string  mobile = 4;
  string className = 5;
  uint32 grade =6;
}

message UpdateStudentRequest{
  uint64  id = 1;
  string name = 2;
  uint32 age = 3;
  uint32 gender = 4;
  string  mobile = 5;
  string className = 6;
  uint32 grade =7;
}

message StudentListResponse {
  string code = 1;
  string msg = 2;
  repeated Student studentList = 3;
}

message CreateStudentResponse {
  string code = 1;
  string msg = 2;
  int64 id = 3;
}

message UpdateStudentResponse {
  string code = 1;
  string msg = 2;

}

message Student {
  string id = 1;          // 学生 ID
  string name = 2;        // 学生姓名
  uint32 age = 3;          // 学生年龄
  uint32 gender = 4;
  string  mobile = 5;
  string className = 6;
  uint32 grade =7;
}

service StudentService {

  rpc studentList(StudentListRequest) returns (StudentListResponse){}
  rpc createStudent(CreateStudentRequest) returns (CreateStudentResponse){}
  rpc updateStudent(UpdateStudentRequest) returns (UpdateStudentResponse){}
}

第二步, 定义编脚本(compile.sh)

上一步中,我们已经定义好了.proto 文件,现在需要来处理一下编译的脚本 如果,在这一步中,如果我们没有安装protobuf 相关的组件,请参考准备阶段的流程,进行安装。 完成安装后,我们在项目的bin目录下,创建一个compile.sh 文件 compile.sh:

#!/bin/bash

function exit_if() {
    extcode=$1
    msg=$2
    if [ $extcode -ne 0 ]
    then
        if [ "msg$msg" != "msg" ]; then
            echo $msg >&2
        fi
        exit $extcode
    fi
}

echo $GOPATH;

if [ ! -f $GOPATH/bin/protoc-gen-go ]
then
    echo 'No plugin for golang installed, skip the go installation' >&2
    echo 'try go get github.com/golang/protobuf/protoc-gen-go' >&2
else
    echo Compiling go interfaces...
    export GO_PATH=$GOPATH
    export GOBIN=$GOPATH/bin
    export PATH=$PATH:$GOPATH/bin

    protoc -I ./ --go_out=./ --go-grpc_out=require_unimplemented_servers=false:. protobuf/*.proto

    exit_if $?
    echo Done
fi

第三步 编写MakeFile

来验证compile.sh 脚本是否能够成功编译成.go代码,以及生成执行文件

school-rpc:
    ./bin/compile.sh
    env GO111MODULE=on go build $(LDFLAGS)
.PHONY: school-rpc

clean:
    rm school-rpc

test:
    go test -v ./...

lint:
    golangci-lint run ./...

同步写一个main.go , main方法中,随便打印一行hello world 即可 此时的整个目录结构如下

image.png Makefile ,main.go ,go.mod , compile.sh,student.proto 一共5个文件,结构还是比较清晰 然后,唤起一个终端,使用 "make" 命令

image.png 如上提示即代表成功,成功后的目录结构下多了school-rpc执行文件 和 student.pb.go & student_grpc.pb.go

image.png

第四步 初始化Sql相关

我们在工程的目录下,创建一个migration的目录,用于存放初始化sql

CREATE TABLE IF NOT EXISTS students (
                          id BIGSERIAL PRIMARY KEY,
                          name VARCHAR(100),
                          age INT,
                          gender INT,
                          mobile VARCHAR(100),
                          class_name VARCHAR(100),
                          grade INT
);

CREATE TABLE IF NOT EXISTS clazz (
                                     id BIGSERIAL PRIMARY KEY,
                                     name VARCHAR(100),
                                     grade INT
)

然后,我们期望在执行makefile的之后,能够根据命令参数,来执行具体的操作。因此我们需要在项目中增加一个cmd的目录,并在其中增加一个cli.go

// 具体执行migration的方法
func runMigrations(ctx *cli.Context) error {
    ctx.Context = opio.CancelOnInterrupt(ctx.Context)
    cfg := config.NewConfig(ctx)
    db, err := database.NewDB(ctx.Context, cfg.Database)
    if err != nil {
       return err
    }
    defer func(db *database.DB) {
       err := db.Close()
       if err != nil {
       }
    }(db)
    err = db.ExecuteSQLMigration(cfg.Migrations)
    if err != nil {
       return err
    }
    return nil
}
// 创建一个cli App实例,其中包含了执行migrations包下面的sql
func NewCli(GitCommit string, GitData string) *cli.App {
    flags := flags2.Flags
    return &cli.App{
       Version:              params.VersionWithCommit(GitCommit, GitData), // 将git提交信息,和版本信息组合在一起生产版本信息
       Description:          "An exchange school services with rpc and rest api server",
       EnableBashCompletion: true,
       Commands: []*cli.Command{
          {
             Name:        "migrate",
             Flags:       flags,
             Description: "Run database migrations",
             Action:      runMigrations,
          },
       },
    }
}

然后,我们在db里面把该有的逻辑进行一下补充

func (db *DB) ExecuteSQLMigration(migrationsFolder string) error {
    err := filepath.Walk(migrationsFolder, func(path string, info os.FileInfo, err error) error {
       if err != nil {
          return errors.Wrap(err, fmt.Sprintf("Failed to process migration file: %s", path))
       }
       if info.IsDir() {
          return nil
       }
       fileContent, readErr := os.ReadFile(path)
       if readErr != nil {
          return errors.Wrap(readErr, fmt.Sprintf("Error reading SQL file: %s", path))
       }
       execErr := db.gorm.Exec(string(fileContent)).Error
       if execErr != nil {
          return errors.Wrap(execErr, fmt.Sprintf("Error executing SQL script: %s", path))
       }
       return nil
    })
    return err
}

如此,我们就把通过命令行,执行初始化sql语句的逻辑给写好了 最后,我们把main.go 移动到/cmd 目录下,同时,添加命令行相关的代码,用于执行相关的命令行

func main() {
    log.SetDefault(log.NewLogger(log.NewTerminalHandlerWithLevel(os.Stderr, log.LevelInfo, true)))
    app := NewCli(GitCommit, GitData)
    // 这个方法的作用是否是增加一个信号中断处理器,用于通知给上下文
    ctx := opio.WithInterruptBlocker(context.Background())
    // 真正执行的是command.go Run方法
    if err := app.RunContext(ctx, os.Args); err != nil {
       log.Error("Application failed")
       os.Exit(1)
    }
}

并且将makefile也一并进行修改

school-rpc:
    ./bin/compile.sh
    env GO111MODULE=on go build -v -o school-rpc $(LDFLAGS) ./cmd

clean:
    rm school-rpc

test:
    go test -v ./...

lint:
    golangci-lint run ./...

第五步 通过flag将配置文件配置好之后,即可初始化数据库

const evnVarPrefix = "SCHOOL"

func prefixEnvVars(name string) []string {
    return []string{evnVarPrefix + "_" + name}
}

var (
    MigrationsFlag = &cli.StringFlag{
       Name:    "migrations-dir",
       Value:   "./migrations",
       Usage:   "path for database migrations",
       EnvVars: prefixEnvVars("MIGRATIONS_DIR"),
    }
    // RpcHostFlag RPC Service
    RpcHostFlag = &cli.StringFlag{
       Name:     "rpc-host",
       Usage:    "The port of the rpc",
       EnvVars:  prefixEnvVars("RPC_HOST"),
       Required: true,
    }
    // RpcPortFlag
    RpcPortFlag = &cli.IntFlag{
       Name:     "rpc-port",
       Usage:    "The port of the rpc",
       EnvVars:  prefixEnvVars("RPC_PORT"),
       Value:    8987,
       Required: true,
    }

    // MetricsHostFlag Metrics
    MetricsHostFlag = &cli.StringFlag{
       Name:     "metrics-host",
       Usage:    "The port of the metrics",
       EnvVars:  prefixEnvVars("METRICS_PORT"),
       Required: true,
    }

    MetricsPortFlag = &cli.IntFlag{
       Name:     "metrics-port",
       Usage:    "The port of the metrics",
       EnvVars:  prefixEnvVars("METRICS_PORT"),
       Value:    7214,
       Required: true,
    }

    // DbHostFlag Database
    DbHostFlag = &cli.StringFlag{
       Name:     "master-db-host",
       Usage:    "The hostname of the database master",
       EnvVars:  prefixEnvVars("DB_HOST"),
       Required: true,
    }
    DbPortFlag = &cli.IntFlag{
       Name:     "master-db-port",
       Usage:    "The port of the master database",
       EnvVars:  prefixEnvVars("DB_PORT"),
       Required: true,
    }
    DbUserFlag = &cli.StringFlag{
       Name:     "master-db-user",
       Usage:    "The user of the master database",
       EnvVars:  prefixEnvVars("DB_USER"),
       Required: true,
    }
    DbPasswordFlag = &cli.StringFlag{
       Name:     "master-db-password",
       Usage:    "The password of the master database",
       EnvVars:  prefixEnvVars("DB_PASSWORD"),
       Required: true,
    }
    DbNameFlag = &cli.StringFlag{
       Name:     "master-db-name",
       Usage:    "The name of the master database",
       EnvVars:  prefixEnvVars("DB_NAME"),
       Required: true,
    }
)

var requireFlags = []cli.Flag{
    MigrationsFlag,
    RpcHostFlag,
    RpcPortFlag,
    MetricsHostFlag,
    MetricsPortFlag,

    DbHostFlag,
    DbPortFlag,
    DbUserFlag,
    DbPasswordFlag,
    DbNameFlag,
}

var optionalFlags = []cli.Flag{}

func init() {
    Flags = append(requireFlags, optionalFlags...)
}

var Flags []cli.Flag

这一步的核心功能是从.env 文件中读取数据,然后生成配置信息,供业务层进行使用 以下是.env 中的信息


export SCHOOL_RPC_PORT=8980
export SCHOOL_RPC_HOST="127.0.0.1"
export SCHOOL_METRICS_PORT=8990
export SCHOOL_METRICS_HOST="127.0.0.1"

export SCHOOL_DB_HOST="127.0.0.1"
export SCHOOL_DB_PORT=5432
export SCHOOL_DB_USER="school"
export SCHOOL_DB_PASSWORD="1234"
export SCHOOL_DB_NAME="school"

注意我们一定需要source .env 一下,要不然执行脚本读取不到.env 文件里面的信息

第六步 初始化数据库

到这一步的时候,我们的工程结构是这样的

image.png

使用 make clean && make 命令,重新生成执行文件 执行./school-rpc 会有命令选项出现

image.png 我们可以执行 ./school-rpc migrate 执行初始化sql,如果没有报错,我们在数据库里面能查到新建的表结构代表创建已经成功了

第七步 实现相关的接口

第一步 在工程下,我们创建一个services 目录,services下分别建立一个 rpcServer.go 和 studentHandle.go

rpcServer.go 主要是用来定义rpc服务端,监听指定的端口号

核心方法为三个

创建 rpcServer实例

根据上下文传递的配置和db,返回rpcServer实例的指针

start方法

创建一个协程,使用配置项所设置的地址和端口号,来呼起一个grpc服务

stop方法

这里只是单纯修改一下rpcServer的状态(可忽略)

const MaxRecvMessageSize = 1024 * 1024 * 300

type RpcServerConfig struct {
    GrpcHostname string
    GrpcPort     int
}

type RpcServer struct {
    *RpcServerConfig
    db *database.DB

    wallet.UnimplementedWalletServiceServer

    stopped atomic.Bool
}

func (s *RpcServer) Stop(ctx context.Context) error {
    s.stopped.Store(true)
    return nil
}

func (s *RpcServer) Stopped() bool {
    //TODO implement me
    panic("implement me")
}

func NewRpcServer(db *database.DB, config *RpcServerConfig) (*RpcServer, error) {
    return &RpcServer{
       RpcServerConfig: config,
       db:              db,
    }, nil
}

func (s *RpcServer) Start(ctx context.Context) error {
    go func(s *RpcServer) {
       addr := fmt.Sprintf("%s:%d", s.GrpcHostname, s.GrpcPort)
       fmt.Println("start rpc server", "addr", addr)
       listener, err := net.Listen("tcp", addr)
       if err != nil {
          fmt.Println("Could not start rpc server", "err", err)
       }

       opt := grpc.MaxRecvMsgSize(MaxRecvMessageSize)
       //创建一个新的 gRPC 服务器实例 gs,并注册反射服务(允许客户端通过反射查询服务信息)。
       gs := grpc.NewServer(opt, grpc.ChainUnaryInterceptor(nil))
       reflection.Register(gs)
       //注册服务
       wallet.RegisterWalletServiceServer(gs, s)
       // 启动grpc服务
       fmt.Println("start rpc server", "port", s.GrpcPort, "address", listener.Addr())
       if err := gs.Serve(listener); err != nil {
          fmt.Println("start rpc server", "err", err)
       }
    }(s)
    return nil
}

studentHandle.go

这部分核心主要就是实现对应的在proto文件中的接口方法的声明

func (s *RpcServer) StudentList(ctx context.Context, request *student.StudentListRequest) (*student.StudentListResponse, error) {
    schoolDB := s.GetRpcSchoolDB()

    studentList, err := schoolDB.FindStudentList(request.GetPageSize(), request.GetPageNo())
    studentPointers := make([]*student.Student, len(studentList))
    for i := range studentList {
        studentPoint := &student.Student{
            Name:      studentList[i].Name,
            Age:       studentList[i].Age,
            Gender:    studentList[i].Gender,
            Mobile:    studentList[i].Mobile,
            ClassName: studentList[i].ClassName,
            Grade:     studentList[i].Grade,
        }
        studentPointers[i] = studentPoint
    }
    if err != nil {
        return nil, err
    }
    return &student.StudentListResponse{
        Code:        strconv.Itoa(200),
        Msg:         "get Student List SUCCESS",
        StudentList: studentPointers,
    }, nil
}

func (s *RpcServer) CreateStudent(ctx context.Context, request *student.CreateStudentRequest) (*student.CreateStudentResponse, error) {
    schoolDB := s.GetRpcSchoolDB()

    err := schoolDB.CreateStudent(&database.Student{
        Name:      request.Name,
        Age:       request.Age,
        Gender:    request.Gender,
        Mobile:    request.Mobile,
        ClassName: request.ClassName,
        Grade:     request.Grade,
    })

    if err != nil {
        return &student.CreateStudentResponse{
            Code: strconv.Itoa(500),
            Msg:  "Create Student Fail",
        }, err
    }

    return &student.CreateStudentResponse{
        Code: strconv.Itoa(200),
        Msg:  "Create Student SUCCESS",
    }, nil
}

func (s *RpcServer) UpdateStudent(ctx context.Context, request *student.UpdateStudentRequest) (*student.UpdateStudentResponse, error) {
    schoolDB := s.GetRpcSchoolDB()

    err := schoolDB.UpdateStudent(&database.Student{
        Id:        request.Id,
        Name:      request.Name,
        Age:       request.Age,
        Gender:    request.Gender,
        Mobile:    request.Mobile,
        ClassName: request.ClassName,
        Grade:     request.Grade,
    })

    if err != nil {
        return &student.UpdateStudentResponse{
            Code: strconv.Itoa(500),
            Msg:  "Create Student Fail",
        }, err
    }

    return &student.UpdateStudentResponse{
        Code: strconv.Itoa(200),
        Msg:  "Create Student SUCCESS",
    }, nil

}

至于DB中的方法,主要就是gorm中的crud方法,限于篇幅,这里不作赘述,有兴趣可以看源码链接

cli.go

命令行 文件中需要增加对 启动服务端程序的实现,以及命令中增加指定的参数选择

func runRpc(ctx *cli.Context, causeFunc context.CancelCauseFunc) (cliapp.Lifecycle, error) {
    fmt.Println("running grpc server...")
    cfg := config.NewConfig(ctx)
    grpcServerCfg := &services.RpcServerConfig{
       GrpcHost: cfg.RpcServer.Host,
       GrpcPort: strconv.Itoa(cfg.RpcServer.Port),
    }
    db, err := database.NewDB(ctx.Context, cfg.Database)
    if err != nil {
       log.Error("failed to connect to database", "err", err)
       return nil, err
    }
    return services.NewRpcServer(grpcServerCfg, db)
}

func NewCli(GitCommit string, GitData string) *cli.App {
    flags := flags2.Flags
    return &cli.App{
       Version:              params.VersionWithCommit(GitCommit, GitData), // 将git提交信息,和版本信息组合在一起生产版本信息
       Description:          "An exchange school services with rpc and rest api server",
       EnableBashCompletion: true,
       Commands: []*cli.Command{
          {
             Name:        "rpc",
             Flags:       flags,
             Description: "Run rpc services",
             Action:      cliapp.LifecycleCmd(runRpc),
          },
          {
             Name:        "migrate",
             Flags:       flags,
             Description: "Run database migrations",
             Action:      runMigrations,
          },
       },
    }
}

从上面的代码中可以看到,两个命令行,一个是run rpc services 的命令,另外一个是执行初始化sql的命令。至此,所有的代码已经ready,可以看看效果了。

第八步,试试RPC 服务端的具体效果

第一步 重新执行一下 make clean && make 。生成最新的执行文件

image.png 可以看到项目的根目录中包含了 school-rpc 执行文件

第二步 再次执行./school-rpc

image.png 可以看到COMMANDS 中增加了rpc 启动的服务选项(rpc)

第三步 启动服务

image.png 正常情况下,当前的服务启动成功了

第四步 启动grpc-ui

如果之前还没有安装的话,可以参考 “准备” 进行安装 grpcui -plaintext 127.0.0.1:8980 执行这个命令,注意host 和 port和 我们启动的服务保持一致即可 它会弹出一个UI页面,我们可以在这个页面上面进行调试 image.png

image.png

源码地址

https://github.com/zhulida1234/school-rpc

  • 原创
  • 学分: 17
  • 分类: Go
  • 标签: gRPC 
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Leo
Leo
江湖只有他的大名,没有他的介绍。