logo

在Go中构建一个简单的gRPC服务

1406
2024年02月05日
这篇文章从构建Go语言gRPC服务的基础知识开始,包括工具使用、最佳实践和设计考虑。

客户端-服务器通信是现代软件架构的基本组成部分。客户端(在各种平台上,包括Web、移动、桌面,甚至物联网设备)请求服务器计算、生成和提供的功能(数据和视图)。已经有几种范式促进了这一点:REST/HttpSOAPXML-RPC等。

gRPC是由Google开发的现代、开源、高性能的远程过程调用(RPC)框架,能够在分布式系统中实现高效的通信。gRPC还使用一种接口定义语言(IDL)——protobuf——来定义服务、定义方法和消息,以及在服务器和客户端之间序列化结构化数据。Protobuf作为一种数据序列化格式非常强大和高效,特别是与基于文本的格式(如JSON)相比。这使得它成为需要高性能和可扩展性的应用程序的绝佳选择。

gRPC的一个主要优势是能够为多种语言(Java、Python、Go、C++、JS/TS)生成多个客户端和服务器的代码,并且能够针对各种平台和框架进行定位。这样以一种与语言和平台无关的方式简化了实现和维护一致的API(通过源-真实IDL)。gRPC还提供了诸如流式传输(单向和双向)、流量控制和灵活的中间件/拦截器机制等功能,使其成为实时应用程序和微服务架构的绝佳选择。

gRPC之所以闪耀是因为它具有插件功能和生态系统,可以在多个方面进行扩展。通过插件,你可以做一些事情,比如:

  • 生成服务器存根以实现你的服务逻辑
  • 生成客户端以与这些服务器通信
  • 针对多种语言(golang、python、typescript等)进行定位
  • 甚至针对多种传输类型(HTTP、TCP等)进行定位

这里有一个精选列表列出了gRPC生态系统的插件。例如,你甚至可以使用适当的插件生成HTTP代理网关以及其自己的OpenAPI规范,以供仍然需要使用它们来消费你的API的人使用。

使用gRPC也存在一些缺点:

  • 作为一种相对较新的技术,学习、设置和使用都有一定的复杂性。这对于来自更传统方法论(如REST)的开发人员来说可能尤为真实。
  • gRPC的浏览器支持可能有限。尽管可以生成Web客户端,但由于组织安全策略的限制,打开对非自定义端口(托管gRPC服务)的访问可能是不可行的。

尽管存在这些缺点(我们认为),其优势胜过了缺点。随着时间的推移,工具的改进,对其的熟悉度增加以及强大的插件生态系统,都使gRPC成为了一个受欢迎的选择。浏览器支持限制将在本系列的未来文章中得到解决。

在本文中,我们将构建一个简单的gRPC服务,以展示常见的特性和模式。这将作为本系列中即将发布的指南的基础。让我们开始吧!

激励示例

让我们构建一个简单的服务,用于支持群聊应用程序(类似于WhatsAppZulipSlackTeams等)。显然,我们的目标不是取代任何现有的热门服务,而是演示支持流行应用类型的强大服务的各个方面。我们的聊天服务——名为OneHub——非常简单。它具有:

  • 主题: 一个相关用户(按团队、项目或兴趣)可以在其中共享消息以相互沟通的地方。它与Slack或Microsoft中的频道非常相似,但也比它们简单得多。
  • 消息: 用户在主题中发送的消息。

(如果你注意到“用户”缺失,那就太棒了。目前,我们将忽略登录/身份验证,并简单地使用不透明的用户ID对待用户。这将简化我们在不用担心登录机制等多个功能的情况下测试服务的多个特性。我们将在另一篇未来的文章中讨论有关身份验证、用户管理甚至社交功能的所有内容)。这项服务虽然基础,但提供了足够的范围,可以引导它朝着多个方向发展,这将成为专门的未来文章的主题。

先决条件

本教程假设你已经安装了以下内容:

  • golang(1.18+)
  • 安装protoc。在OSX上,只需运行brew install protobuf即可。
  • 用于生成Go的gRPC protoc工具
    • go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
    • go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

可选项

我们不会过多地讨论Go之外的服务构建,但只是为了好玩,我们还将生成一些Python存根,以展示一切是多么容易,如果有一天有很多人需要,可能会扩展本系列,更详细地涵盖其他语言。

  • 用于生成Python的gRPC protoc工具
    • pyenv virtualenv onehub
    • pyenv activate onehub
    • pip install grpcio
    • pip install grpcio-tools

设置你的项目

这部分的代码已经可以在OneHub存储库中找到。该存储库按服务组织,并且使用分支作为本系列每个部分结束时的检查点,以便更轻松地重新访问。

mkdir onehub
cd onehub
# 用你自己的github存储库路径替换这个
go mod init github.com/panyam/onehub
mkdir -p protos/onehub/v1
touch protos/onehub/v1/models.proto
touch protos/onehub/v1/messages.proto
touch protos/onehub/v1/topics.proto

# 安装依赖项
go get google.golang.org/grpc

当创建你的proto时,最好将它们进行版本化(上面的v1)。

有几种组织你的proto文件的方法,例如(但不限于):

  1. 一个包含所有模型和proto的整个服务的巨型proto(例如,onehub.proto)
  2. 所有与Foo实体相关的模型和服务都在foo.proto中
  3. 一个单一proto中的所有模型(models.proto),并伴随着foo.proto中的实体Foo的服务。

在本系列中,我们使用第三种方法,因为它还允许我们在服务之间共享模型,同时清晰地分离各个实体服务。

定义你的服务

.proto文件是gRPC的起点,因此我们可以从那里开始,添加一些基本的细节

模型

syntax = "proto3";
import "google/protobuf/timestamp.proto";
import "google/protobuf/struct.proto";

option go_package = "github.com/onehub/protos";
package onehub.v1;

// 用户将发布消息的主题
message Topic {
  google.protobuf.Timestamp created_at = 1;
  google.protobuf.Timestamp updated_at = 2;

  // 主题的ID
  string id = 3;

  // 创建此主题的用户的ID
  string creator_id = 4;
  
  // 用户可以使用的主题的唯一名称
  string name = 5;

  // 此主题中的用户的ID。目前没有关于他们参与的信息。
  repeated string users = 6;
}

/**
 * 主题中的单个消息
 */
message Message {
  /**
   * 服务器上创建消息的时间。
   */
  google.protobuf.Timestamp created_at = 1;

  /**
   * 当消息或其正文最后修改时(如果可能的话)。
   */
  google.protobuf.Timestamp updated_at = 2;

  /**
   * 在主题内保证唯一的消息ID。
   * 仅由服务器设置,不可修改。
   */
  string id = 3;

  /**
   * 发送此消息的用户。
   */
  string user_id = 4;

  /**
   * 消息所属的主题。这仅由服务器设置,不可修改。
   */
  string topic_id = 5;

  /**
   * 消息的内容类型。可以是类似于ContentType http标头的内容,也可以是自定义的shell/command之类的内容。
   */
  string content_type = 6;

  /**
   * 一种简单的发送文本的方式。
   */
  string content_text = 7;

  // 本地存储的数据的原始内容,以JSON格式存储
  // 请注意,我们可以结合文本、URL和数据来显示视图/UI中的不同内容
  google.protobuf.Struct content_data = 8;
}

主题服务

查看完整代码。

syntax = "proto3";
import "google/protobuf/field_mask.proto";

option go_package = "github.com/onehub/protos";
package onehub.v1;

import "onehub/v1/models.proto";

/**
 * 用于操作主题的服务
 */
service TopicService {
  /**
   * 创建一个新的会话
   */
  rpc CreateTopic(CreateTopicRequest) returns (CreateTopicResponse) {
  }

  /**
   * 列出用户的所有主题。
   */
  rpc ListTopics(ListTopicsRequest) returns (ListTopicsResponse) { 
  }

  /**
   * 获取特定主题
   */
  rpc GetTopic(GetTopicRequest) returns (GetTopicResponse) { 
  }

  /**
   * 通过ID批量获取多个主题
   */
  rpc GetTopics(GetTopicsRequest) returns (GetTopicsResponse) { 
  }

  /**
   * 删除特定主题
   */
  rpc DeleteTopic(DeleteTopicRequest) returns (DeleteTopicResponse) { 
  }

  /**
   * 更新主题的特定字段
   */
  rpc UpdateTopic(UpdateTopicRequest) returns (UpdateTopicResponse) {
  }
}

/**
 * 主题创建请求对象
 */
message CreateTopicRequest {
  /**
   * 正在更新的主题
   */
  Topic topic = 1;
}

/**
 * 主题创建的响应。
 */
message CreateTopicResponse {
  /**
   * 正在创建的主题
   */
  Topic topic = 1;
}

/**
 * 主题搜索请求。目前只提供分页参数。
 */
message ListTopicsRequest {
  /**
   * 提供一个不透明的“指针”,指向结果集中的某个偏移量,而不是一个偏移量。
   */
  string page_key = 1;

  /**
   * 要返回的结果数。
   */
  int32 page_size = 2;
}

/**
 * 主题搜索/列表的响应。
 */
message ListTopicsResponse {
  /**
   * 作为此响应的一部分找到的主题列表。
   */
  repeated Topic topics = 1;

  /**
   * 后续列表请求应传递的键/指针字符串,以继续分页。
   */
  string next_page_key = 2;
}

/**
 * 获取主题的请求。
 */
message GetTopicRequest {
  /**
   * 要获取的主题的ID
   */
  string id = 1;
}

/**
 * 主题获取响应
 */
message GetTopicResponse {
  Topic topic = 1;
}

/**
 * 批量获取主题的请求
 */
message GetTopicsRequest {
  /**
   * 要获取的主题的ID
   */
  repeated string ids = 1;
}

/**
 * 主题批量获取响应
 */
message GetTopicsResponse {
  map<string, Topic> topics = 1;
}

/**
 * 删除主题的请求。
 */
message DeleteTopicRequest {
  /**
   * 要删除的主题的ID。
   */
  string id = 1;
}

/**
 * 主题删除响应
 */
message DeleteTopicResponse {
}

/**
 * (部分)更新主题的请求。
 */
message UpdateTopicRequest {
  /**
   * 正在更新的主题
   */
  Topic topic = 1;

  /**
   * 此主题中正在更新的字段掩码,以进行部分更改。
   */
  google.protobuf.FieldMask update_mask = 2;

  /**
   * 要添加到此主题的用户的ID。
   */
  repeated string add_users = 3;

  /**
   * 要从此主题中删除的用户的ID。
   */
  repeated string remove_users = 4;
}

/**
 * (部分)更新主题的响应。
 */
message UpdateTopicResponse {
  /**
   * 正在更新的主题
   */
  Topic topic = 1;
}
#### 消息服务

[在此查看完整代码。](https://raw.githubusercontent.com/panyam/onehub/PART1/protos/onehub/v1/messages.proto)

```go
syntax = "proto3";
import "google/protobuf/field_mask.proto";

option go_package = "github.com/onehub/protos";
package onehub.v1;

import "onehub/v1/models.proto";

/**
 * 用于操作消息的服务
 */
service MessageService {
  /**
   * 创建新会话
   */
  rpc CreateMessage(CreateMessageRequest) returns (CreateMessageResponse) {
  }

  /**
   * 列出主题中的所有消息
   */
  rpc ListMessages(ListMessagesRequest) returns (ListMessagesResponse) { 
  }

  /**
   * 获取特定消息
   */
  rpc GetMessage(GetMessageRequest) returns (GetMessageResponse) { 
  }

  /**
   * 通过ID批量获取多条消息
   */
  rpc GetMessages(GetMessagesRequest) returns (GetMessagesResponse) { 
  }

  /**
   * 删除特定消息
   */
  rpc DeleteMessage(DeleteMessageRequest) returns (DeleteMessageResponse) { 
  }

  /**
   * 在主题中更新消息
   */
  rpc UpdateMessage(UpdateMessageRequest) returns (UpdateMessageResponse) {
  }
}

/**
 * 消息创建请求对象
 */
message CreateMessageRequest {
  /**
   * 要更新的消息
   */
  Message message = 1;
}

/**
 * 消息创建响应
 */
message CreateMessageResponse {
  /**
   * 被创建的消息
   */
  Message message = 1;
}

/**
 * 消息列表请求。目前仅提供分页参数。
 */
message ListMessagesRequest {
  /**
   * 提供一个抽象的“页面”键,而不是偏移量,它提供了一个不透明的“指针”,指向结果集中的某个偏移量。
   */
  string page_key = 1;

  /**
   * 要返回的结果数。
   */
  int32 page_size = 2;

  /**
   * 要列出消息的主题。必填。
   */
  string topic_id = 3;
}

/**
 * 主题搜索/列表响应
 */
message ListMessagesResponse {
  /**
   * 作为响应的一部分找到的主题列表。
   */
  repeated Message messages = 1;

  /**
   * 后续列表请求应传递的键/指针字符串,以继续分页。
   */
  string next_page_key = 2;
}

/**
 * 获取单个消息的请求
 */
message GetMessageRequest {
  /**
   * 要获取的主题的ID
   */
  string id = 1;
}

/**
 * 消息获取响应
 */
message GetMessageResponse {
  Message message = 1;
}

/**
 * 批量获取消息的请求
 */
message GetMessagesRequest {
  /**
   * 要获取的消息的ID
   */
  repeated string ids = 1;
}

/**
 * 消息批量获取响应
 */
message GetMessagesResponse {
  map<string, Message> messages = 1;
}

/**
 * 删除消息的请求
 */
message DeleteMessageRequest {
  /**
   * 要删除的消息的ID。
   */
  string id = 1;
}

/**
 * 消息删除响应
 */
message DeleteMessageResponse {
}

message UpdateMessageRequest {
  // 要更新的消息。必须在此消息对象中指定主题ID和消息ID字段。其他字段的使用方式由update_mask参数确定,从而实现部分更新
  Message message = 1;

  // 指示正在更新的字段
  // 如果未提供field_mask,则我们将拒绝替换(根据标准约定所需)以防止错误的完全替换。而是必须传递"*"的update_mask。
  google.protobuf.FieldMask update_mask = 3;

  // 此处指定的任何字段将被“追加”而不是被替换
  google.protobuf.FieldMask append_mask = 4;
}

message UpdateMessageResponse {
  // 更新后的消息
  Message message = 1;
}

请注意,每个实体都被分配到自己的服务中,尽管这并不意味着要将其翻译成单独的服务器(甚至进程)。这只是为了方便。

在很大程度上,实体、它们各自的服务和方法都采用了面向资源的设计。总结如下:

  • 实体(在models.proto中)具有一个id字段,用于表示它们的主键/对象ID
  • 所有实体都有一个创建/更新时间戳,在创建和更新方法中设置
  • 所有服务都有典型的CRUD方法
  • 每个服务中的方法(rpc)遵循类似的模式,用于它们的CRUD方法,例如:
    • FooService.Get => method(GetFooRequest) => GetFooResponse
    • FooService.Delete => method(DeleteFooRequest) => DeleteFooResponse
    • FooService.Create => method(CreateFooRequest) => CreateFooResponse
    • FooService.Update => method(UpdateFooRequest) => UpdateFooResponse
  • FooServer.Create方法接受一个Foo实例,并设置实例的id、created_at和updated_at字段
  • FooService.Update方法接受一个Foo实例以及一个update_mask,用于突出被更改的字段并更新这些字段。此外,它还忽略id方法,因此id不能被覆盖。

这些实体(和关系)非常直接。一些(稍微)值得注意的方面包括:

  1. 主题具有表示主题中参与者的ID列表(我们暂时不关注来自大量用户的主题的可扩展性瓶颈)。
  2. 消息保存对主题的引用(通过topic_id)。
  3. 消息非常简单,仅支持文本消息(以及通过content_data传递额外信息或稍微自定义的消息类型)。

生成服务存根和客户端

protoc命令行工具确保从这个基本定义生成服务器(存根)和客户端。

protoc的魔力在于它本身不会生成任何东西。相反,它使用不同“目的”的插件来生成自定义的工件。首先,让我们生成go工件:

SRC_DIR=<ONEHUB的绝对路径>
PROTO_DIR:=$SRC_DIR/protos
OUT_DIR:=$SRC_DIR/gen/go
protoc --go_out=$OUT_DIR --go_opt=paths=source_relative               \
       --go-grpc_out=$OUT_DIR --go-grpc_opt=paths=source_relative     \
       --proto_path=$(PROTO_DIR)                                      \
        $PROTO_DIR/onehub/v1/*.proto

这相当繁琐,因此我们可以将其添加到一个Makefile中,并简单地运行make all以生成proto并构建以后的所有内容。

Makefile

在此查看代码。

# 一些用于确定go位置等的变量
GOROOT=$(which go)
GOPATH=$(HOME)/go
GOBIN=$(GOPATH)/bin

# 评估包含此Makefile的目录的绝对路径
SRC_DIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))

# protos所在的位置
PROTO_DIR:=$(SRC_DIR)/protos

# 我们要生成服务器存根、客户端等的位置
OUT_DIR:=$(SRC_DIR)/gen/go

all: printenv goprotos

goprotos:
    echo "生成GO绑定"
    rm -Rf $(OUT_DIR) && mkdir -p $(OUT_DIR)
    protoc --go_out=$(OUT_DIR) --go_opt=paths=source_relative              \
       --go-grpc_out=$(OUT_DIR) --go-grpc_opt=paths=source_relative        \
       --proto_path=$(PROTO_DIR)                                                                             \
      $(PROTO_DIR)/onehub/v1/*.proto

printenv:
    @echo MAKEFILE_LIST=$(MAKEFILE_LIST)
    @echo SRC_DIR=$(SRC_DIR)
    @echo PROTO_DIR=$(PROTO_DIR)
    @echo OUT_DIR=$(OUT_DIR)
    @echo GOROOT=$(GOROOT)
    @echo GOPATH=$(GOPATH)
    @echo GOBIN=$(GOBIN)

现在,所有生成的存根都可以在gen/go/onehub/v1文件夹中找到(因为我们的protos文件夹托管了onehub/v1中的服务定义)。

简而言之,创建了以下内容:

  • 对于每个X.proto文件,都会创建一个gen/go/onehub/v1/X.pb.go文件。该文件包含.proto文件中每个“消息”的模型定义(例如,Topic和Message)。
  • 对于包含service定义的每个Y.proto文件,都会生成一个X_grpc.pb.go文件,其中包含:
    • 必须实现的服务器接口(在下一节中介绍)。
    • 对于服务X,生成一个名为XService的接口,其中的方法都是Y.proto文件中规定的所有RPC方法。
    • 生成一个客户端,可以与运行中的XService接口实现进行通信(在下面介绍)。

相当强大,不是吗?现在,让我们看看如何实际实现服务。

实现您的服务

我们的服务非常简单。它们将不同的实例存储在内存中,作为按创建顺序添加的一组简单元素,并通过查询和更新此集合来提供服务。由于所有服务都有(大多数)相似的实现(CRUD),因此已创建了一个基本存储对象来表示内存中的集合,并且服务简单地使用此存储。

这个(简单的)基本实体看起来像这样:

基本实体存储

查看完整代码。

package services

import (
    "fmt"
    "log"
    "sort"
    "time"

    tspb "google.golang.org/protobuf/types/known/timestamppb"
)

type EntityStore[T any] struct {
    IDCount  int
    Entities map[string]*T

    // ID的获取器/设置器
    IDSetter func(entity *T, id string)
    IDGetter func(entity *T) string

    // 创建时间戳的获取器/设置器
    CreatedAtSetter func(entity *T, ts *tspb.Timestamp)
    CreatedAtGetter func(entity *T) *tspb.Timestamp

    // 更新时间戳的获取器/设置器
    UpdatedAtSetter func(entity *T, ts *tspb.Timestamp)
    UpdatedAtGetter func(entity *T) *tspb.Timestamp
}

func NewEntityStore[T any]() *EntityStore[T] {
    return &EntityStore[T]{
        Entities: make(map[string]*T),
    }
}

func (s *EntityStore[T]) Create(entity *T) *T {
    s.IDCount++
    newid := fmt.Sprintf("%d", s.IDCount)
    s.Entities[newid] = entity
    s.IDSetter(entity, newid)
    s.CreatedAtSetter(entity, tspb.New(time.Now()))
    s.UpdatedAtSetter(entity, tspb.New(time.Now()))
    return entity
}

func (s *EntityStore[T]) Get(id string) *T {
    if entity, ok := s.Entities[id]; ok {
        return entity
    }
    return nil
}

func (s *EntityStore[T]) BatchGet(ids []string) map[string]*T {
    out := make(map[string]*T)
    for _, id := range ids {
        if entity, ok := s.Entities[id]; ok {
            out[id] = entity
        }
    }
    return out
}

// 更新实体的特定字段
func (s *EntityStore[T]) Update(entity *T) *T {
    s.UpdatedAtSetter(entity, tspb.New(time.Now()))
    return entity
}

// 从我们的系统中删除实体。
func (s *EntityStore[T]) Delete(id string) bool {
    _, ok := s.Entities[id]
    if ok {
        delete(s.Entities, id)
    }
    return ok
}

// 查找并检索与特定条件匹配的实体。
func (s *EntityStore[T]) List(ltfunc func(t1, t2 *T) bool, filterfunc func(t *T) bool) (out []*T) {
    log.Println("E: ", s.Entities)
    for _, ent := range s.Entities {
        if filterfunc == nil || filterfunc(ent) {
            out = append(out, ent)
        }
    }
    // 按名称的反向顺序排序
    sort.Slice(out, func(idx1, idx2 int) bool {
        ent1 := out[idx1]
        ent2 := out[idx2]
        return ltfunc(ent1, ent2)
    })
    return
}

使用这个,主题服务现在非常简单:

主题服务实现

查看完整代码。

package services

import (
    context"
    "log"
    "strings"

    protos "github.com/panyam/onehub/gen/go/onehub/v1"
    tspb "google.golang.org/protobuf/types/known/timestamppb"
)

type TopicService struct {
    protos.UnimplementedTopicServiceServer
    *EntityStore[protos.Topic]
}

func NewTopicService(estore *EntityStore[protos.Topic]) *TopicService {
    if estore == nil {
        estore = NewEntityStore[protos.Topic]()
    }
    estore.IDSetter = func(topic *protos.Topic, id string) { topic.Id = id }
    estore.IDGetter = func(topic *protos.Topic) string { return topic.Id }

    estore.CreatedAtSetter = func(topic *protos.Topic, val *tspb.Timestamp) { topic.CreatedAt = val }
    estore.CreatedAtGetter = func(topic *protos.Topic) *tspb.Timestamp { return topic.CreatedAt }

    estore.UpdatedAtSetter = func(topic *protos.Topic, val *tspb.Timestamp) { topic.UpdatedAt = val }
    estore.UpdatedAtGetter = func(topic *protos.Topic) *tspb.Timestamp { return topic.UpdatedAt }

    return &TopicService{
        EntityStore: estore,
    }
}

// 创建新主题
func (s *TopicService) CreateTopic(ctx context.Context, req *protos.CreateTopicRequest) (resp *protos.CreateTopicResponse, err error) {
    resp = &protos.CreateTopicResponse{}
    resp.Topic = s.EntityStore.Create(req.Topic)
    return
}

// 通过ID获取单个主题
func (s *TopicService) GetTopic(ctx context.Context, req *protos.GetTopicRequest) (resp *protos.GetTopicResponse, err error) {
    log.Println("通过ID获取主题:", req.Id)
    resp = &protos.GetTopicResponse{
        Topic: s.EntityStore.Get(req.Id),
    }
    return
}

// 批量获取多个主题
func (s *TopicService) GetTopics(ctx context.Context, req *protos.GetTopicsRequest) (resp *protos.GetTopicsResponse, err error) {
    log.Println("批量获取ID:", req.Ids)
    resp = &protos.GetTopicsResponse{
        Topics: s.EntityStore.BatchGet(req.Ids),
    }
    return
}

// 更新主题的特定字段
func (s *TopicService) UpdateTopic(ctx context.Context, req *protos.UpdateTopicRequest) (resp *protos.UpdateTopicResponse, err error) {
    resp = &protos.UpdateTopicResponse{
        Topic: s.EntityStore.Update(req.Topic),
    }
    return
}

// 从我们的系统中删除主题。
func (s *TopicService) DeleteTopic(ctx context.Context, req *protos.DeleteTopicRequest) (resp *protos.DeleteTopicResponse, err error) {
    resp = &protos.DeleteTopicResponse{}
    s.EntityStore.Delete(req.Id)
    return
}

// 查找并检索与特定条件匹配的主题。
func (s *TopicService) ListTopics(ctx context.Context, req *protos.ListTopicsRequest) (resp *protos.ListTopicsResponse, err error) {
    results := s.EntityStore.List(func(s1, s2 *protos.Topic) bool {
        return strings.Compare(s1.Name, s2.Name) < 0
    }, nil)
    log.Println("找到主题:", results)
    resp = &protos.ListTopicsResponse{Topics: results}
    return
}

消息服务也非常相似,可以在这里找到。

用Runner包装所有内容

我们已经用我们的逻辑实现了服务,但是服务需要被启动。

一般的步骤是:

  • 创建一个GRPC服务器实例
  • 将我们的每个服务实现注册到这个服务器上
  • 在特定端口上运行这个服务器

主服务器CLI

完整代码在这里。

package main

import (
    "flag"
    "log"
    "net"

    "google.golang.org/grpc"

    v1 "github.com/panyam/onehub/gen/go/onehub/v1"
    svc "github.com/panyam/onehub/services"

    // 这是为了启用grpc_cli工具的使用
    "google.golang.org/grpc/reflection"
)

var (
    addr = flag.String("addr", ":9000", "Address to start the onehub grpc server on.")
)

func startGRPCServer(addr string) {
    // 创建新的gRPC服务器
    server := grpc.NewServer()
    v1.RegisterTopicServiceServer(server, svc.NewTopicService(nil))
    v1.RegisterMessageServiceServer(server, svc.NewMessageService(nil))
    if l, err := net.Listen("tcp", addr); err != nil {
        log.Fatalf("error in listening on port %s: %v", addr, err)
    } else {
        // gRPC服务器
        log.Printf("Starting grpc endpoint on %s:", addr)
        reflection.Register(server)
        if err := server.Serve(l); err != nil {
            log.Fatal("unable to start server", err)
        }
    }
}

func main() {
    flag.Parse()
    startGRPCServer(*addr)
}

这个服务器现在可以运行(默认在9000端口):

go run cmd/server.go

请注意,这是一个简单的服务,具有Unary RPC方法。也就是说,客户端向服务器发送单个请求,然后等待单个响应。还有其他类型的方法。

  1. 服务器流式RPC: 客户端向服务器发送请求,并接收一系列响应(类似于HTTP中的长轮询,客户端在开放连接上监听消息的块)。
  2. 客户端流式RPC: 在这里,客户端在单个请求中发送一系列消息,并从服务器接收单个响应。例如,客户端的单个请求可能涉及多个位置更新(随时间分散),服务器的响应可能是客户端沿着的单个“路径”对象。
  3. 双向流式RPC: 客户端与服务器建立连接,客户端和服务器都可以独立地发送消息。在HTTP世界中,类似的是Websocket连接。

我们将在未来的教程中实现其中一个或多个。

客户端调用服务器

现在,是时候测试我们的服务器了。请注意,grpc服务器不是REST端点。因此,curl不起作用(我们将在下一部分中介绍这一点)。我们可以以几种方式调用服务器 —— 使用CLI实用程序(类似于REST/HTTP服务的curl)或使用protocol工具生成的客户端。更好的是,我们还可以从其他语言进行客户端调用 —— 如果我们选择生成针对这些语言的库的话。

通过grpc_cli实用程序调用服务器

存在一个grpc客户端(grpc_cli),可以直接从命令行进行调用。在OSX上,可以使用brew install grpc进行安装。

如果服务器没有运行,那么继续启动它(如前一部分所述)。现在我们可以开始在服务器本身上调用操作 —— 要么进行调用,要么进行反射!

列出所有操作

grpc_cli ls localhost:9000 -l

filename: grpc/reflection/v1/reflection.proto
package: grpc.reflection.v1;
service ServerReflection {
  rpc ServerReflectionInfo(stream grpc.reflection.v1.ServerReflectionRequest) returns (stream grpc.reflection.v1.ServerReflectionResponse) {}
}

filename: grpc/reflection/v1alpha/reflection.proto
package: grpc.reflection.v1alpha;
service ServerReflection {
  rpc ServerReflectionInfo(stream grpc.reflection.v1alpha.ServerReflectionRequest) returns (stream grpc.reflection.v1alpha.ServerReflectionResponse) {}
}

filename: onehub/v1/messages.proto
package: onehub.v1;
service MessageService {
  rpc CreateMessage(onehub.v1.CreateMessageRequest) returns (onehub.v1.CreateMessageResponse) {}
  rpc ListMessages(onehub.v1.ListMessagesRequest) returns (onehub.v1.ListMessagesResponse) {}
  rpc GetMessage(onehub.v1.GetMessageRequest) returns (onehub.v1.GetMessageResponse) {}
  rpc GetMessages(onehub.v1.GetMessagesRequest) returns (onehub.v1.GetMessagesResponse) {}
  rpc DeleteMessage(onehub.v1.DeleteMessageRequest) returns (onehub.v1.DeleteMessageResponse) {}
  rpc UpdateMessage(onehub.v1.UpdateMessageRequest) returns (onehub.v1.UpdateMessageResponse) {}
}

filename: onehub/v1/topics.proto
package: onehub.v1;
service TopicService {
  rpc CreateTopic(onehub.v1.CreateTopicRequest) returns (onehub.v1.CreateTopicResponse) {}
  rpc ListTopics(onehub.v1.ListTopicsRequest) returns (onehub.v1.ListTopicsResponse) {}
  rpc GetTopic(onehub.v1.GetTopicRequest) returns (onehub.v1.GetTopicResponse) {}
  rpc GetTopics(onehub.v1.GetTopicsRequest) returns (onehub.v1.GetTopicsResponse) {}
  rpc DeleteTopic(onehub.v1.DeleteTopicRequest) returns (onehub.v1.DeleteTopicResponse) {}
  rpc UpdateTopic(onehub.v1.UpdateTopicRequest) returns (onehub.v1.UpdateTopicResponse) {}
}

创建一个主题

grpc_cli --json_input --json_output call localhost:9000 CreateTopic '{topic: {name: "First Topic", creator_id: "user1"}}'

{
 "topic": {
  "createdAt": "2023-07-28T07:30:54.633005Z",
  "updatedAt": "2023-07-28T07:30:54.633006Z",
  "id": "1",
  "creatorId": "user1",
  "name": "First Topic"
 }
}

还有一个

grpc_cli --json_input --json_output call localhost:9000 CreateTopic '{topic: {name: "Urgent topic", creator_id: "user2", users: ["user1", "user2", "user3"]}}'

{
 "topic": {
  "createdAt": "2023-07-28T07:32:04.821800Z",
  "updatedAt": "2023-07-28T07:32:04.821801Z",
  "id": "2",
  "creatorId": "user2",
  "name": "Urgent topic",
  "users": [
   "user1",
   "user2",
   "user3"
  ]
 }
}

列出所有主题

grpc_cli --json_input --json_output call localhost:9000 ListTopics {}

{
 "topics": [
  {
   "createdAt": "2023-07-28T07:30:54.633005Z",
   "updatedAt": "2023-07-28T07:30:54.633006Z",
   "id": "1",
   "creatorId": "user1",
   "name": "First Topic"
  },
  {
   "createdAt": "2023-07-28T07:32:04.821800Z",
   "updatedAt": "2023-07-28T07:32:04.821801Z",
   "id": "2",
   "creatorId": "user2",
   "name": "Urgent topic",
   "users": [
    "user1",
    "user2",
    "user3"
   ]
  }
 ]
}

通过ID获取主题

grpc_cli --json_input --json_output call localhost:9000 GetTopics '{"ids": ["1", "2"]}'

{
 "topics": {
  "1": {
   "createdAt": "2023-07-28T07:30:54.633005Z",
   "updatedAt": "2023-07-28T07:30:54.633006Z",
   "id": "1",
   "creatorId": "user1",
   "name": "First Topic"
  },
  "2": {
   "createdAt": "2023-07-28T07:32:04.821800Z",
   "updatedAt": "2023-07-28T07:32:04.821801Z",
   "id": "2",
   "creatorId": "user2",
   "name": "Urgent topic",
   "users": [
    "user1",
    "user2",
    "user3"
   ]
  }
 }
}

删除一个主题,然后进行列表

grpc_cli --json_input --json_output call localhost:9000 DeleteTopic '{"id": "1"}'

connecting to localhost:9000
{}
Rpc succeeded with OK status

grpc_cli --json_input --json_output call localhost:9000 ListTopics {}

{
 "topics": [
  {
   "createdAt": "2023-07-28T07:32:04.821800Z",
   "updatedAt": "2023-07-28T07:32:04.821801Z",
   "id": "2",
   "creatorId": "user2",
   "name": "Urgent topic",
   "users": [
    "user1",
    "user2",
    "user3"
   ]
  }
 ]
}

以编程方式调用服务器

不要深入研究这一点,服务文件夹中的测试展示了如何创建客户端以及如何编写测试。

结论

总之,gRPC是现代软件开发的重要组成部分,它允许开发人员构建高性能、可扩展和可互操作的系统。它的特性和优势使其成为需要处理大量数据或需要支持跨多个平台的实时通信的公司的热门选择。

在本文中,我们:

  • 从头开始在Go中创建了一个grpc服务,具有非常简单的实现(遗憾的是缺乏持久性),
  • 使用CLI实用程序调用正在运行的服务
  • 使用生成的客户端进行调用,同时编写测试
本文链接:https://www.iokks.com/art/62ad43937e99
本博客所有文章除特别声明外,均采用CC BY 4.0 CN协议 许可协议。转载请注明出处!