研究gRPC所给的helloworld例子

Posted on 2024-07-12 16:49  Aderversa  阅读(79)  评论(0编辑  收藏  举报

这里我以编写一个远程过程调用,客户端传过来请求,远程过程调用就可以返回当前时间。(daytime服务器熟知端口是13,这里并不是搭建daytime,只是为了测试远程过程调用是否成功)

CMakeLists.txt文件的编写

cmake_minimum_required(VERSION 3.8)
project(HelloWorld C CXX)
include(../cmake/common.cmake)

前两句都是标准的CMakeLists.txt的开头,后面的include​为本项目引入了一个叫common.cmake​的文件,那这个文件里面有什么东西?

common.cmake的内容解析

cmake_minimum_required(VERSION 3.8)
if(MSVC)
	add_definitions(-D_WIN32_WINNT=0x600)
endif()
find_package(Threads REQUIRED)

这里好像探测了一下是否是在Windows平台下的MSVC编译的,并且为MSVC编译器定义了某个参数。

后面又去找包,这个package它cmake怎么找到的,我不记得自己安装过这样的库

find_package(Threads REQUIRED)会让cmake依据不同的平台(Linux、Windows等)查找能使用的线程库,比如:Linux下的POSIX,Windows的线程库。

CMake会自动为我们配置项目以使用系统的线程库。一旦成功,你就可以在你的CMakeLists.txt中使用由Threads模块提供的变量和命令来编写使用线程的代码。

但这样我有个问题,不同平台的线程库接口API应该不同吧!该如何进行兼容?

if(GRPC_AS_SUBMODULE)
	add_subdirectory(../../.. ${CMAKE_CURRENT_BINARY_DIR}/grpc EXCLUDE_FROM_ALL)
	message(STATUS "Using gRPC via add_subdirectory.")
	set(_PROTOBUF_LIBPROTOBUF libprotobuf)
	set(_REFLECTION grpc++_reflection)
	if(CMAKE_CROSSCOMPILING)
    	find_program(_PROTOBUF_PROTOC protoc)
  	else()
    	set(_PROTOBUF_PROTOC $<TARGET_FILE:protobuf::protoc>)
  	endif()
  	set(_GRPC_GRPCPP grpc++)
  	if(CMAKE_CROSSCOMPILING)
    	find_program(_GRPC_CPP_PLUGIN_EXECUTABLE grpc_cpp_plugin)
  	else()
    	set(_GRPC_CPP_PLUGIN_EXECUTABLE $<TARGET_FILE:grpc_cpp_plugin>)
  	endif()
elseif(GRPC_FETCHCONTENT)
	message(STATUS "Using gRPC via add_subdirectory (FetchContent).")
    include(FetchContent)
	  FetchContent_Declare(
	    grpc
	    GIT_REPOSITORY https://github.com/grpc/grpc.git
	    GIT_TAG        vGRPC_TAG_VERSION_OF_YOUR_CHOICE)
	  FetchContent_MakeAvailable(grpc)
  	set(_PROTOBUF_LIBPROTOBUF libprotobuf)
	set(_REFLECTION grpc++_reflection)
	set(_PROTOBUF_PROTOC $<TARGET_FILE:protoc>)
	set(_GRPC_GRPCPP grpc++)
	if(CMAKE_CROSSCOMPILING)
	  find_program(_GRPC_CPP_PLUGIN_EXECUTABLE grpc_cpp_plugin)
	else()
	  set(_GRPC_CPP_PLUGIN_EXECUTABLE $<TARGET_FILE:grpc_cpp_plugin>)
	endif()
else()
  option(protobuf_MODULE_COMPATIBLE TRUE)
  find_package(Protobuf CONFIG REQUIRED)
  message(STATUS "Using protobuf ${Protobuf_VERSION}")

  set(_PROTOBUF_LIBPROTOBUF protobuf::libprotobuf)
  set(_REFLECTION gRPC::grpc++_reflection)
  if(CMAKE_CROSSCOMPILING)
    find_program(_PROTOBUF_PROTOC protoc)
  else()
    set(_PROTOBUF_PROTOC $<TARGET_FILE:protobuf::protoc>)
  endif()
  # Find gRPC installation
  # Looks for gRPCConfig.cmake file installed by gRPC's cmake installation.
  find_package(gRPC CONFIG REQUIRED)
  message(STATUS "Using gRPC ${gRPC_VERSION}")

  set(_GRPC_GRPCPP gRPC::grpc++)
  if(CMAKE_CROSSCOMPILING)
    find_program(_GRPC_CPP_PLUGIN_EXECUTABLE grpc_cpp_plugin)
  else()
    set(_GRPC_CPP_PLUGIN_EXECUTABLE $<TARGET_FILE:gRPC::grpc_cpp_plugin>)
  endif()
endif()

对于第一个分支,具有以下注释:

构建使用gRPC的项目的一种方式是只通过add_subdirectory()​将整个gRPC项目包含起来。

这个方法使用起来很简单,但存在以下问题:

  • 它将gRPC的CMakeLists.txt直接包含到你的构建脚本中而不使用,这可能使得gRPC在内部干扰你自己的构建
  • 取决于你的系统上安装了什么,子模块的内容gRPC的third_party/*中的可能需要是可用的(并且可能有构建它们所需的前置条件)

添加对gRPC的依赖性的一种更健壮的方法是使用cmake的ExternamlProject_Add()​详细请参考cmake的相关文档

对于第二个分支,具有以下注释:

另一种方法是使用CMake的FetchContent模块克隆gRPC在配置时间里。

这使得gRPC的源代码可用于你的项目,类似与git子模块

此分支假定gRPC及其所有依赖项都已安装
因此可以通过find_package()找到它们。

查找Protobuf安装
查找由protobuf的cmake安装程序安装的protobuf-config.cmake文件。

我在使用的时候,为了避免麻烦的环境配置,可以直接使用第二种,如果已经完成了麻烦的配置,使用第三种无疑是性能最好的。

总结:common.cmake文件介绍了如何使用CMake为CPP项目引入gRPC依赖的方法,总共介绍了三种。

get_filename_component(hw_proto "../../protos/hellowrold.proto" ABSOLUTE)
get_filename_component(hw_proto_path "hw_proto" PATH)

这两语句为项目引入了一个helloworld.proto​文件,大概是为了让本项目可以直接使用该文件,或者利用该文件做出某些操作。

set(hw_proto_srcs "${CMAKE_CURRENT_BINARY_DIR}/helloworld.pb.cc")
set(hw_proto_hdrs "${CMAKE_CURRENT_BINARY_DIR}/helloworld.pb.h")
set(hw_grpc_srcs "${CMAKE_CURRENT_BINARY_DIR}/helloworld.grpc.pb.cc")
set(hw_grpc_hdrs "${CMAKE_CURRENT_BINARY_DIR}/helloworld.grpc.pb.h")

最后,将某些文件定义为了一个变量,这些文件应该在后面需要进行一些操作,注意:由于这些文件不是一开始就存在于BINARY_DIR中的,需要CMake执行完配置之后才会出现。


add_custom_command(
      OUTPUT "${hw_proto_srcs}" "${hw_proto_hdrs}" "${hw_grpc_srcs}" "${hw_grpc_hdrs}"
      COMMAND ${_PROTOBUF_PROTOC}
      ARGS --grpc_out "${CMAKE_CURRENT_BINARY_DIR}"
        --cpp_out "${CMAKE_CURRENT_BINARY_DIR}"
        -I "${hw_proto_path}"
        --plugin=protoc-gen-grpc="${_GRPC_CPP_PLUGIN_EXECUTABLE}"
        "${hw_proto}"
      DEPENDS "${hw_proto}")

在common.cmake中,会找出Protocol Buffers的编译器protoc,并将其设置到变量_PROTOBUF_PROTOC​上面,也就是说这一步执行的是protoc的编译操作。

我并不知道add_custom_command()​命令的功能,所以需要查询相关资料:

add_custion_command()允许你向构建系统中添加自定义的编译规则。这个命令主要用于以下几个场景:

  1. 生成源文件:在编译之前生成源代码文件,这些文件可由其他工具(如脚本、代码生成器等)生成的
  2. 预处理或后处理:在编译前或后执行特定的预处理或后处理
  3. 创建自定义的构建步骤

add_custom_command()可以通过多种方式被调用,包括指定目标(TARGET)、源文件(SOURCE)或独立于任何目标的自定义命令(COMMAND)

如果没有指定TARGET或SOURCE,将定义一个独立的自定义命令。这些命令的执行时机取决于它们是如何被调用的。例如,你可以使用add_custom_target()创建一个自定义的目标,并在该目标中调用这些自定义命令,从而控制它们的执行时机。


include_directories("${CMAKE_CURRENT_BINARY_DIR}")

包含通过protoc生成的头文件


add_library(hw_grpc_proto
  ${hw_grpc_srcs}
  ${hw_grpc_hdrs}
  ${hw_proto_srcs}
  ${hw_proto_hdrs})

利用protoc生成的文件,生成一个库,生成这些库不需要我们自己编写的C++代码,只是需要一个.proto​文件


target_link_libraries(hw_grpc_proto
  absl::check
  ${_REFLECTION}
  ${_GRPC_GRPCPP}
  ${_PROTOBUF_LIBPROTOBUF})

这个库需要连接一些依赖库,使用到的变量在common.cmake中被定义


foreach(_target
  greeter_client greeter_server
  greeter_callback_client greeter_callback_server
  greeter_async_client greeter_async_client2 greeter_async_server)
  add_executable(${_target} "${_target}.cc")
  target_link_libraries(${_target}
    hw_grpc_proto
    absl::check
    absl::flags
    absl::flags_parse
    absl::log
    ${_REFLECTION}
    ${_GRPC_GRPCPP}
    ${_PROTOBUF_LIBPROTOBUF})
endforeach()

使用foreach()批量生成可执行文件

protoc编译生成了什么东西?

helloworld.pb.h​文件中,首先定义了一个google::protobuf::internal::AnyMetadata​注意,命令空间google::protobuf::internal​里面只有一个AnyMetadata​而且这个类中啥玩意没有。

namespace google {
namespace protobuf {
namespace internal {
class AnyMetadata;
}  // namespace internal
}  // namespace protobuf
}  // namespace google

然后这一堆我也看不懂:

// Internal implementation detail -- do not use these members.
struct TableStruct_helloworld_2eproto {
  static const ::uint32_t offsets[];
};
extern const ::google::protobuf::internal::DescriptorTable
    descriptor_table_helloworld_2eproto;

重点是知道在helloworld.proto​文件中定义了一个package名为:helloworld

所以这里生成了类好像也放到了名为helloworld​的namespace中

namespace helloworld {
// HelloReply是在proto文件中定义好的传回参数
class HelloReply;
struct HelloReplyDefaultTypeInternal; // 这个我也不太懂是什么玩意
extern HelloReplyDefaultTypeInternal _HelloReply_default_instance_; // 生成一个上面的结构体的实例并暴露给外界,虽然我不知道有什么用

// HelloRequest是在proto文件中定义好的传入参数
class HelloRequest;
struct HelloRequestDefaultTypeInternal;
extern HelloRequestDefaultTypeInternal _HelloRequest_default_instance_;
}  // namespace helloworld

最后就是为我们生成了HelloReply和HelloRequest这两个类的实现代码,这两个类的父类都是:google::protobuf::Message​。

然后在这之上实现了一些方法,不过都是自动生成的,我暂时只需要关心如何使用这些生成的方法,了解它们有什么功能即可。

那么helloworld.pb.cc​很大概率就是helloworld.pb.h​对应的实现文件。


那么我现在好奇的是,helloworld.grpc.pb.h​这里定义了什么东西。

查看代码的结构可以发现,该文件定义了一个Greeter​类,该类中定义了Stub​和Service​(这两个都是类,但定义在Greeter内部)也就是说客户端和服务端都定义好了。还定义了一些在proto文件中定义好的方法,如:SayHello​。观看源码发现,还提供了一些异步的方法,应该是用于处理异步请求和回复。

理解上完全没有困难的好吧,只需要知道如何引入gRPC剩下的就是利用这些生成好的代码进行服务的搭建。

使用gRPC编写自定义的远程过程调用

利用examples中的分支3引入对gRPC的依赖,来搭建项目

不论是分支1直接导入整个项目过于巨大,而分支2导入gRPC由于网络问题和项目大小几乎是不可行

所以,使用分支3,也就是在本地安装库然后导入。

那么是否需要自己写内容到CMakeLists.txt文件来导入呢?

完全不需要,因为在官方所给的例子中,有给一个common.cmake​这个就是导入gRPC依赖的标准解决方案。我们要做的,无非在自己的CMakeLists.txt中定义变量,选择不同的分支(导入方式),如果是选择分支3,那么什么变量也不需要定义,因为这就是默认的导入方式。

在编写proto​文件,并利用protoc​和其gRPC插件编译proto​文件中我发现:

如果我编写的是echo.proto​文件,则编译后会产生:四个同名文件,即:

  • echo.pb.cc
  • echo.pb.h​。声明消息类
  • echo.grpc.pb.cc
  • echo.grpc.pb.h​。声明服务Service和Stub

也就是说,生成的文件名只和.proto​的文件名有关,而与该文件的具体内容无关。

但是生成的文件内容是和.proto​文件的内容有关的。

比如,我编写的echo.proto​文件内容如下:

syntax = "proto3";

package EchoTime;

service Echo {
    rpc  Time(TimeRequest) returns (TimeResponse);
}

message TimeRequest {
}

message TimeResponse {
    string time = 1;
}

那么,在echo.pb.h​中就会声明EchoTime::TimeRequest​,EchoTime::TimeResponse​前面的命名空间是由package​来指定的。这些类的一些方法将在echo.pb.cc​中得到实现。

echo.grpc.pb.h​中,EchoTime::Echo​会得到声明,而在EchoTime::Echo​中,由会有两个重要的类:

  1. EchoTime::Echo::Stub​.这是一个实现类,它本身会实现一个我在proto文件中定义的Time()​方法(该方法似乎是同步的)。这个实现了中实现了异步的方法,放在了EchoTime::Echo::async​中,可以类似于Netty设置监听者的方式,异步处理事件。
  2. EchoTime::Echo::Service​。这是一个接口类,我们需要自己实现在echo.proto​中定义的Time()​方法。具体做法就是:定义一个实现类ServiceImpl,然后实现Time()方法。

CMakeLists.txt

cmake_minimum_required(VERSION 3.22)
project(gRPC_try)

set(CMAKE_CXX_STANDARD 20)

include(common.cmake)

get_filename_component(echo_proto "./echo.proto" ABSOLUTE)
get_filename_component(echo_proto_path "${echo_proto}" PATH)

set(echo_proto_srcs "${CMAKE_CURRENT_BINARY_DIR}/echo.pb.cc") # 用ProtoBuf生成的消息类的实现
set(echo_proto_hdrs "${CMAKE_CURRENT_BINARY_DIR}/echo.pb.h") # 用ProtoBuf生成的消息类的声明
set(echo_grpc_srcs "${CMAKE_CURRENT_BINARY_DIR}/echo.grpc.pb.cc") # gRPC的Service和Stub的实现
set(echo_grpc_hdrs "${CMAKE_CURRENT_BINARY_DIR}/echo.grpc.pb.h") # gRPC的Service和Stub的声明
# 这里唯一的疑问就是,如何知道该command输出文件的顺序?
add_custom_command(
        OUTPUT "${echo_proto_srcs}" "${echo_proto_hdrs}" "${echo_grpc_srcs}" "${echo_grpc_hdrs}"
        COMMAND ${_PROTOBUF_PROTOC}
        ARGS --grpc_out "${CMAKE_CURRENT_BINARY_DIR}"
        --cpp_out "${CMAKE_CURRENT_BINARY_DIR}"
        -I ${echo_proto_path}
        --plugin=protoc-gen-grpc="${_GRPC_CPP_PLUGIN_EXECUTABLE}"
        "${echo_proto}"
        DEPENDS "${echo_proto}"
)
include_directories("${CMAKE_CURRENT_BINARY_DIR}")

add_library(echo_grpc_proto
    ${echo_grpc_srcs} ${echo_grpc_hdrs}
    ${echo_proto_srcs} ${echo_proto_hdrs}
)

target_link_libraries(echo_grpc_proto
    absl::check
    ${_REFLECTION}
    ${_GRPC_GRPCPP}
    ${_PROTOBUF_LIBPROTOBUF}
)

foreach (_target echo_server)
    add_executable(${_target} "${_target}.cpp")
    target_link_libraries(${_target}
        echo_grpc_proto
        absl::check
        absl::flags
        absl::flags_parse
        absl::log
        ${_REFLECTION}
        ${_GRPC_GRPCPP}
        ${_PROTOBUF_LIBPROTOBUF}
    )
endforeach ()

如何编写同步的服务器

#include <sstream>
#include <memory>
#include <chrono>
#include <string>

#include "absl/flags/flag.h"
#include "absl/flags/parse.h"
#include "grpcpp/ext/proto_server_reflection_plugin.h"
#include "grpcpp/grpcpp.h"
#include "grpcpp/health_check_service_interface.h"


#include "echo.grpc.pb.h"

using EchoTime::Echo;
using EchoTime::TimeRequest;
using EchoTime::TimeResponse;
using grpc::Status; // 这个不是头文件,不会暴露给外界,所以不用担心
using grpc::Server;
using grpc::ServerContext;

// 定义全局变量,并交给FLAG管理,这样可以在不将变量暴露给全局的情况下使用它
// 想要查看怎么实现的可以去研究一下源码,有兴趣就去看
ABSL_FLAG(uint16_t, port, 10000, "Server port for the service");

// 实现EchoTime::Service
class EchoServiceImpl final : public Echo::Service {
public:
    Status Time(ServerContext* context, const TimeRequest* request, TimeResponse* response) override {
        std::stringstream ss;
        // 获取当前时间点
        auto now = std::chrono::system_clock::now();
        // 将时间点转换为time_t类型,以便使用ctime库函数
        std::time_t now_c = std::chrono::system_clock::to_time_t(now);
        // 转换为本地时间
        std::tm* now_tm = std::localtime(&now_c);
        // 格式化时间输出
        ss << (now_tm->tm_year + 1900) << '-'
                  << (now_tm->tm_mon + 1) << '-'
                  << now_tm->tm_mday
                  << " " << now_tm->tm_hour << ":"
                  << now_tm->tm_min << ":"
                  << now_tm->tm_sec << '\n';
        response->set_time(ss.str()); // 这个time是在proto文件定义好的名字
        return Status::OK;
    }
};

void RunServer(uint16_t port) {
    std::string server_address = absl::StrFormat("0.0.0.0:%d", port);
    EchoServiceImpl service;
    // 以下两语句我没研究明白,官方的例子中要这样写咱也这样写,遵守别人的规定
    grpc::EnableDefaultHealthCheckService(true); // 来自"grpcpp/grpcpp.h"
    grpc::reflection::InitProtoReflectionServerBuilderPlugin();

    // 后面的就是利用Builder来创建一个Server的故事了,基本上没有理解障碍
    grpc::ServerBuilder builder;
    builder.AddListeningPort(server_address, grpc::InsecureServerCredentials());
    builder.RegisterService(&service);
    std::unique_ptr<Server> server(builder.BuildAndStart());
    std::cout << "Server listening on" << server_address << std::endl;
    server->Wait();
}


int main(int argc, char** argv) {
    absl::ParseCommandLine(argc, argv);
    RunServer(absl::GetFlag(FLAGS_port));
    return 0;
}

服务器成功运转,接下来就是编写使用该服务器的客户端。

总结:

对于RPC中服务器端的编写主要步骤为:

  1. 以继承的方式,重写Service中的我们所定义的方法
  2. 完成上述方法的业务逻辑
  3. 使用ServerBuilder生成Service

客户端的编写

#include <memory>
#include <iostream>
#include <string>

#include "absl/flags/flag.h"
#include "absl/flags/parse.h"

#include <grpcpp/grpcpp.h>

#include "echo.grpc.pb.h"

ABSL_FLAG(std::string, target, "localhost:10000", "Server address");

using grpc::Channel;
using grpc::ClientContext;
using grpc::Status;
using EchoTime::Echo;
using EchoTime::TimeRequest;
using EchoTime::TimeResponse;

class ClockClient {
public:
    ClockClient(std::shared_ptr<Channel> channel)  : stub_(Echo::NewStub(channel)) {}
    std::string Time() {
        TimeRequest request;
        TimeResponse response;
        ClientContext context;
        Status status = stub_->Time(&context, request, &response);
        if(status.ok()) {
            return response.time();
        }
        else {
            std::cerr << status.error_code() << ": " << status.error_message() << std::endl;
            return "RPC failed";
        }
    }

private:
    std::unique_ptr<Echo::Stub> stub_;
};

int main(int argc, char** argv) {
    absl::ParseCommandLine(argc, argv);
    std::string target_str = absl::GetFlag(FLAGS_target);
    ClockClient clock(CreateChannel(target_str, grpc::InsecureChannelCredentials()));
    std::string now = clock.Time();
    std::cout << "Now time is " << now << std::endl;
    return 0;
}

成功运行,运行结果的时间我对照了一下当前时间,基本可以确定它是正确的。

总结:

客户端重点就是:准备Stub,然后准备请求的参数,准备好用来接收返回值的变量,调用方法即可。基本上跟普通的函数调用是一回事。

Copyright © 2024 Aderversa
Powered by .NET 9.0 on Kubernetes