这里我以编写一个远程过程调用,客户端传过来请求,远程过程调用就可以返回当前时间。(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()允许你向构建系统中添加自定义的编译规则。这个命令主要用于以下几个场景:
- 生成源文件:在编译之前生成源代码文件,这些文件可由其他工具(如脚本、代码生成器等)生成的
- 预处理或后处理:在编译前或后执行特定的预处理或后处理
- 创建自定义的构建步骤
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
中,由会有两个重要的类:
EchoTime::Echo::Stub
.这是一个实现类,它本身会实现一个我在proto文件中定义的Time()
方法(该方法似乎是同步的)。这个实现了中实现了异步的方法,放在了EchoTime::Echo::async
中,可以类似于Netty设置监听者的方式,异步处理事件。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中服务器端的编写主要步骤为:
- 以继承的方式,重写Service中的我们所定义的方法
- 完成上述方法的业务逻辑
- 使用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,然后准备请求的参数,准备好用来接收返回值的变量,调用方法即可。基本上跟普通的函数调用是一回事。