C++ 动态库热加载

本文参考自 project-based-learning 中的 Build a Live Code-reloader Library for C++,主要内容都来自于其中,但是对代码进行了一点修改,并且改用 CMake 进行构建。

文章整体比较基础,适合初学者,通过本文可以学习到以下知识点

  1. 关于 C++ 程序如何编译运行,如何运行时加载动态库(使用 dl* API)。
  2. 如何设计简洁易用的库 API 供用户使用。
  3. 如何使用 CMake 组织并构建一个包含可执行程序、动态库和头文件库的项目。
  4. 如何使用 GoogleTest 进行测试。

动态库热加载原理

动态库热加载指的是在程序运行时,动态地加载动态库,从而达到不停止程序的情况下,更新程序的功能。

C++ 程序在运行时有两种方式加载动态连接库:隐式链接和显式链接 [1]

  1. 隐式链接就是在编译的时候使用 -l 参数链接的动态库,进程在开始执行时就将动态库文件映射到内存空间中。
  2. 显式链接使用 libdl.so 库的 API 接口在运行中加载和卸载动态库,主要的 API 有 dlopen、dlclose、dlsym、dlerror

隐式链接的方式要进行热加载需要不少 Hack,难度较大,本文主要讲解第二种方式。

简单版本

首先我们快速实现一个能够完成最小功能可运行的版本,熟悉相关 API 的使用。我们简单编写三个文件,分别为main.cppreplex.hhello.cpp,另外还编写一个快速编译运行代码的脚本 run.sh,目录结构如下

.
├── hello.cpp
├── main.cpp
├── replex.h
└── run.sh

代码的完整版本见 projects/replex-1

replex.h 中对 dl* API 进行了简单的封装,使用一个 namespace 将 API 进行了包装,代码如下

#pragma once

#include <dlfcn.h>

#include <cstdio>

namespace Replex {

inline void* Load(const char* filepath) {
return dlopen(filepath, RTLD_LAZY);
} inline void* LoadSymbol(void* library, const char* symbol) {
return dlsym(library, symbol);
} inline void Reload(void*& library, const char* filepath) {
if (library) {
dlclose(library);
}
library = Load(filepath);
} inline void PrintError() {
fprintf(stderr, "%s\n", dlerror());
} } // namespace Replex

hello.cpp 是我们需要热加载的动态库,代码如下

#include <cstdio>

extern "C" {
void foo() {
printf("Hi\n");
} int bar = 200;
}

其中使用 extern "C"foobar 声明为 C 语言的函数和变量,这样在编译时就不会对函数名进行修饰,否则在 main.cpp 中使用 dlsym 时会找不到 foo 对应的符号。

不加 extern "C"时,使用 nm 命令查看 hello.so 中的符号如下

$ nm libhello.so  | grep foo
0000000000001119 T _Z3foov

加上后

$ nm libhello.so  | grep foo
0000000000001119 T foo

main.cpp 是主程序,代码如下

#include <cstdio>
#include <string> #include "replex.h" const char* g_libPath = "libhello.so"; int main() {
void* handle;
void (*foo)();
int bar; handle = Replex::Load(g_libPath);
if (!handle) {
Replex::PrintError();
return -1;
}
foo = reinterpret_cast<void (*)()>(Replex::LoadSymbol(handle, "foo"));
foo();
bar = *reinterpret_cast<int*>(Replex::LoadSymbol(handle, "bar"));
printf("bar == %d\n", bar); // Modify the source code and recompile the library.
std::string filename = "hello.cpp";
std::string command = std::string("sed -i ") +
(bar == 200 ? "'s/200/300/'" : "'s/300/200/'") + " " +
filename;
system(command.c_str());
command = std::string("sed -i ") +
(bar == 200 ? "'s/Hi/Hello/'" : "'s/Hello/Hi/'") + " " + filename;
system(command.c_str());
system("g++ -shared -fPIC -o libhello.so hello.cpp"); Replex::Reload(handle, g_libPath);
if (!handle) {
Replex::PrintError();
return -1;
}
foo = reinterpret_cast<void (*)()>(Replex::LoadSymbol(handle, "foo"));
foo();
bar = *reinterpret_cast<int*>(Replex::LoadSymbol(handle, "bar"));
printf("bar == %d\n", bar); return 0;
}

整体代码逻辑比较好懂,首先加载动态库,然后获取动态库中的函数和变量,调用函数和打印变量,然后修改 hello.cpp 中的代码,重新编译动态库,再次加载动态库,调用函数和打印变量。

reinterpret_cast 是 C++ 中的强制类型转换,将 void* 指针转换为函数指针和变量指针。

run.sh 的内容如下

#!/bin/bash
set -e # stop the script on errors
g++ -fPIC -shared -o libhello.so hello.cpp
g++ -o main.out main.cpp -ldl
./main.out

脚本中 -shared -fPIC 参数用于生成位置无关的动态库,-ldl 参数用于链接 libdl.so 库(dl* API),-o 参数用于指定输出文件名。

运行脚本后,输出如下

Hi
bar == 200
Hello
bar == 300

当前程序能够完成基本功能,但是对于使用者来说我们的库不够好用,使用者(main.cpp)需要自己定义相应的函数指针和类型,还需要自己进行类型转换,动态库的导出符号也需要自己定义,对于使用者来说也相当麻烦。

改进版本

我们考虑提供更简单的接口供用户使用,我们将在 replex.h 中创建一个 ReplexModule 类,这个类将用于给动态库的继承使用,然后由动态库的作者提供更加简明的接口供用户使用。

这一版本代码的完整实现见 GitHub

最终的使用效果见如下 main.cpp 文件

#include <iostream>

#include "hello.h"

int main() {
HelloModule::LoadLibrary();
HelloModule::Foo();
int bar = HelloModule::GetBar();
std::cout << "bar == " << bar << std::endl; // Modify the source code and recompile the library.
// ... HelloModule::ReloadLibrary();
HelloModule::Foo();
std::cout << "bar == " << HelloModule::GetBar() << std::endl;
return 0;
}

我们忽略中间的修改源码和重新编译的过程,这里只关注 HelloModule 的使用,相比于前一版本,这里的使用更加简单,不需要自己定义函数指针和变量,也不需要自己进行类型转换,只需要调用 HelloModule 中的接口即可。同时注意到我们包含的头文件也变成了 hello.h,这个头文件是动态库作者提供的,我们在 main.cpp 中只需要包含这个头文件即可。

针对于上述需求,ReplexModule 需要公开两个公共接口,一个用于发布可热加载库,另一个用于加载和重新加载这些可热加载库。

ReplexModule 的公开接口仅有两个,分别为 LoadLibraryReloadLibrary,代码如下

#pragma once

#include <dlfcn.h>

#include <array>
#include <iostream>
#include <stdexcept>
#include <string>
#include <unordered_map> template <typename E, size_t NumSymbols>
class ReplexModule {
public:
static void LoadLibrary() { GetInstance().Load(); }
static void ReloadLibrary() { GetInstance().Reload(); } protected:
static E& GetInstance() {
static E instance;
return instance;
} // ...
// ... continued later
}

这两个函数都依赖于 GetInstance 函数,这个函数是一个模板函数,用于返回 ReplexModule 的子类的单例,这样可以保证每个子类只有一个实例。另外,ReplexModule 是一个模板类,模板参数 E 是一个枚举类型,用于指定动态库中的符号,NumSymbols 是一个常量,用于指定动态库中的符号个数。

接下来关注 ReplexModule 向动态库作者也就是集成该类的子类提供的接口,代码如下:

    // ... continued above

    // Should return the path to the library on disk
virtual const char* GetPath() const = 0; // Should return a reference to an array of C-strings of size NumSymbols
// Used when loading or reloading the library to lookup the address of
// all exported symbols
virtual std::array<const char*, NumSymbols>& GetSymbolNames() const = 0; template <typename Ret, typename... Args>
Ret Execute(const char* name, Args... args) {
// Lookup the function address
auto symbol = m_symbols.find(name);
if (symbol != m_symbols.end()) {
// Cast the address to the appropriate function type and call it,
// forwarding all arguments
return reinterpret_cast<Ret (*)(Args...)>(symbol->second)(args...);
}
throw std::runtime_error(std::string("Function not found: ") + name);
} template <typename T>
T* GetVar(const char* name) {
auto symbol = m_symbols.find(name);
if (symbol != m_symbols.end()) {
return static_cast<T*>(symbol->second);
}
// We didn't find the variable. Return an empty pointer
return nullptr;
} private:
void Load() {
m_libHandle = dlopen(GetPath(), RTLD_NOW);
LoadSymbols();
} void Reload() {
auto ret = dlclose(m_libHandle);
m_symbols.clear();
Load();
} void LoadSymbols() {
for (const char* symbol : GetSymbolNames()) {
auto* sym = dlsym(m_libHandle, symbol);
m_symbols[symbol] = sym;
}
} void* m_libHandle;
std::unordered_map<std::string, void*> m_symbols;
};

首先关注最底部的数据成员,m_libHandle 是动态库的句柄,m_symbols 是一个哈希表,用于存储动态库中的符号和符号对应的地址。 Load 函数用于加载动态库,Reload 函数用于重新加载动态库,LoadSymbols 函数用于加载动态库中的符号,这几个函数的逻辑相当清晰无需赘述。

值得讲解的是 ExecuteGetVar 函数,Execute 函数用于调用动态库中的函数,GetVar 函数用于获取动态库中的变量,让我们先看看 Execute 函数的实现,代码如下

    template <typename Ret, typename... Args>
Ret Execute(const char* name, Args... args) {
// Lookup the function address
auto symbol = m_symbols.find(name);
if (symbol != m_symbols.end()) {
// Cast the address to the appropriate function type and call it,
// forwarding all arguments
return reinterpret_cast<Ret (*)(Args...)>(symbol->second)(args...);
}
throw std::runtime_error(std::string("Function not found: ") + name);
}

这是一个模板函数,模板参数 Ret 是返回值类型,Args... 是参数类型,这里的 Args... 表示可以接受任意多个参数,Args... args 表示将参数包 args 展开,然后将展开后的参数作为参数传递给 Execute 函数。

该函数首先在 m_symbols 中查找 name 对应的符号,如果找到了,就将符号地址转换为类型为 Ret (*)(Args...) 的函数指针,然后调用该函数,传递参数 args...,如果没有找到,就抛出异常。

GetVar 函数的实现如下

    template <typename T>
T* GetVar(const char* name) {
auto symbol = m_symbols.find(name);
if (symbol != m_symbols.end()) {
return static_cast<T*>(symbol->second);
}
// We didn't find the variable. Return an empty pointer
return nullptr;
}

该函数的实现和 Execute 函数类似,只是将函数指针转换为变量指针,然后返回。

hello.cpp 的内容保持不变:

#include <cstdio>

extern "C" {
void foo() {
printf("Hi\n");
} int bar = 200;
}

hello.h 中定义类 HelloModule 继承自 ReplexModule,代码如下

#pragma once
#include <array> #include "replex.h" inline std::array<const char*, 2> g_exports = {"foo", "bar"}; class HelloModule : public ReplexModule<HelloModule, g_exports.size()> {
public:
static void Foo() { GetInstance().Execute<void>("foo"); } static int GetBar() { return *GetInstance().GetVar<int>("bar"); } protected:
virtual const char* GetPath() const override { return "libhello.so"; } virtual std::array<const char*, g_exports.size()>& GetSymbolNames()
const override {
return g_exports;
}
};

变量 g_exports 用于存储动态库中需要导出的符号,其采用 inline 修饰,这样就可以在头文件中定义,而不会出现重复定义的错误。

HelloModule 中定义了两个静态函数,分别为 FooGetBar,这两个函数用于调用动态库中的函数和获取动态库中的变量。

运行脚本的内容基本不变,添加了 -std=c++17 的标志保证可以使用 inline 变量的用法。

#!/bin/bash
set -e # stop the script on errors
g++ -fPIC -shared -o libhello.so hello.cpp -std=c++17
g++ -o main.out main.cpp -ldl -std=c++17
./main.out

运行效果与前一版本一致,如下

Hi
bar == 200
Hello
bar == 300

现在我们可以认为我们所编写的 replex.h 库足方便使用,动态库作者只需要继承 ReplexModule 类,然后实现两个虚函数即可,使用者只需要包含动态库作者提供的头文件,然后调用相应的接口即可。

CMake 版本

前面两个版本的代码都是写个脚本直接使用 g++ 编译,这样的方式不够灵活,不利于项目的管理,正好这个项目涉及到几个不同的模块,可以尝试使用 CMake 进行管理,学习一下项目的组织构建。

完整代码见 projects/replex-3,采用 现代 CMake 模块化项目管理指南 中推荐的方式进行项目组织,但是略微进行了一点简化,目录结构如下

.
├── CMakeLists.txt
├── hello
│ ├── CMakeLists.txt
│ ├── include
│ │ └── hello.h
│ └── src
│ └── hello.cpp
├── main
│ ├── CMakeLists.txt
│ └── src
│ └── main.cpp
└── replex
├── CMakeLists.txt
└── include
└── replex.h

首先梳理一下整个项目的依赖关系,如下所示

main (exe)
├── hello_interface (interface)
│ └── replex (interface)
└── hello (shared lib)

main 模块依赖于头文件库 hello_interface,hello_interface 依赖于头文件库 replex,动态库 hello 不依赖于任何库,用于提供给 main 模块使用。

CMakeLists.txt 为根目录的 CMakeLists.txt,内容如下

cmake_minimum_required(VERSION 3.15)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) project(replex LANGUAGES CXX) if (NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif () add_subdirectory(replex)
add_subdirectory(main)
add_subdirectory(hello)

首先设置 C++ 标准,然后设置项目名称,然后判断是否设置了构建类型,如果没有设置,则设置为 Release 模式,然后添加子目录,分别为 replex、main 和 hello。

replex/CMakeLists.txt 的内容如下

add_library(replex INTERFACE include/replex.h)
target_include_directories(replex INTERFACE include)

replex 为头文件库,使用 add_library 添加,类型为 INTERFACE,表示这是一个接口库,不会生成任何文件,只会导出头文件,使用 target_include_directories 添加头文件路径。

hello/CMakeLists.txt 的内容如下

add_library(hello SHARED src/hello.cpp)

add_library(hello_interface INTERFACE include/hello.h)
target_include_directories(hello_interface INTERFACE include)
target_link_libraries(hello_interface INTERFACE replex)

其中定义了两个库,一个为动态库 hello,一个为头文件库 hello_interface 用于导出 动态库 hello 中的符号以供使用, hello_interface 依赖于 replex,使用 target_link_libraries 添加依赖。

main/CMakeLists.txt 的内容如下

add_executable(main src/main.cpp)
target_link_libraries(main PRIVATE hello_interface)

main 为可执行文件,使用 add_executable 添加,使用 target_link_libraries 添加依赖 hello_interface

最后运行脚本 run.sh,内容如下

#!/bin/bash
set -e # stop the script on errors
cmake -B build
cmake --build build
./build/main/main

运行的效果如下

Hi
bar == 200
[ 0%] Built target replex
[ 0%] Built target hello_interface
[ 50%] Built target main
[ 75%] Building CXX object hello/CMakeFiles/hello.dir/src/hello.cpp.o
[100%] Linking CXX shared library libhello.so
[100%] Built target hello
Hello
bar == 300

添加测试 (GoogleTest

这部分的完整代码见 projects/replex-4

一个好的项目,测试是必不可少的,前面我们实现的 main.cpp 中其实已经有了一点自动化测试的影子,但是这种方式不够好,我们可以使用 GoogleTest 来进行测试。

首先演示一个最基本的 gtest 用法,首先使用 git 的 submodule 命令添加 googletest 到我们的项目中

git submodule add git@github.com:google/googletest.git

然后修改我们根目录下的 CMakeLists.txt,添加如下内容

add_subdirectory(googletest)
enable_testing()
include_directories(${gtest_SOURCE_DIR}/include ${gtest_SOURCE_DIR}) add_subdirectory(test)

创建 test 目录,结构如下

test
├── CMakeLists.txt
└── src
└── test.cpp

test/CMakeLists.txt 的内容如下

add_executable(tests src/test.cpp)
target_link_libraries(tests PUBLIC gtest gtest_main)

test/src/test.cpp 的内容如下

#include <gtest/gtest.h>

TEST(SillyTest, IsFourPositive) {
EXPECT_GT(4, 0);
} TEST(SillyTest, IsFourTimesFourSixteen) {
int x = 4;
EXPECT_EQ(x * x, 16);
} int main(int argc, char** argv) {
// This allows us to call this executable with various command line
// arguments which get parsed in InitGoogleTest
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

OK,到现在我们已经成功添加了 GoogleTest 到我们的项目中并且可以运行测试了,现在我们要编写一些测试来测试我们的项目。

我们编写一个 replex 的测试,测试内容如下

#include <gtest/gtest.h>
#include <hello.h> #include <cstdlib>
#include <fstream> const char* g_Test_v1 = R"delimiter(
extern "C" {
int foo(int x) {
return x + 5;
}
int bar = 3;
}
)delimiter"; const char* g_Test_v2 = R"delimiter(
extern "C" {
int foo(int x) {
return x - 5;
}
int bar = -2;
}
)delimiter"; class ReplexTest : public ::testing::Test {
public:
// Called automatically at the start of each test case.
virtual void SetUp() {
WriteFile("hello/src/hello.cpp", g_Test_v1);
Compile(1);
HelloModule::LoadLibrary();
} // We'll invoke this function manually in the middle of each test case
void ChangeAndReload() {
WriteFile("hello/src/hello.cpp", g_Test_v2);
Compile(2);
HelloModule::ReloadLibrary();
} // Called automatically at the end of each test case.
virtual void TearDown() {
HelloModule::UnloadLibrary();
WriteFile("hello/src/hello.cpp", g_Test_v1);
Compile(1);
} private:
void WriteFile(const char* path, const char* text) {
// Open an output filetream, deleting existing contents
std::ofstream out(path, std::ios_base::trunc | std::ios_base::out);
out << text;
} void Compile(int version) {
if (version == m_version) {
return;
} m_version = version;
EXPECT_EQ(std::system("cmake --build build"), 0); // Super unfortunate sleep due to the result of cmake not being fully
// flushed by the time the command returns (there are more elegant ways
// to solve this)
sleep(1);
} int m_version = 1;
}; TEST_F(ReplexTest, VariableReload) {
EXPECT_EQ(HelloModule::GetBar(), 3);
ChangeAndReload();
EXPECT_EQ(HelloModule::GetBar(), -2);
} TEST_F(ReplexTest, FunctionReload) {
EXPECT_EQ(HelloModule::Foo(4), 9);
ChangeAndReload();
EXPECT_EQ(HelloModule::Foo(4), -1);
} int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

要使得这个测试运行起来,还需要对 CMake 文件进行一些修改,这部分留作练习吧,动手试试会对 CMake 等有更深的理解。

相比较于 projects/replex-3,需要修改的文件有:

  1. 移除 main 文件夹
  2. 根目录下的 CMakeLists.txt
  3. hello/CMakeLists.txt
  4. hello/include/hello.h
  5. test/src/test.cpp

完整代码见 projects/replex-4


  1. Linux 下 C++so 热更新

C++ 动态库热加载的更多相关文章

  1. c++ 动态库的加载

    转载:https://blog.csdn.net/ztq_12345/article/details/99677769 使用ide是vs, 使用Windows.h下的3个函数对动态库进行加载第一个:H ...

  2. 动态库DLL加载方式-静态加载和动态加载

    静态加载: 如果你有a.dll和a.lib,两个文件都有的话可以用静态加载的方式: message函数的声明你应该知道吧,把它的声明和下面的语句写到一个头文件中 #pragma comment(lib ...

  3. 关于Linux动态库的加载路径

    问题 按如下步骤在Ubuntu上编译安装Google Protocol Buffers $ ./configure $ make $ make check $ sudo make install 运行 ...

  4. qt在动态库里面加载widget的例子

    testDll和testExe项目 备注:windows下dll内不需要new QApplication, linux和mac下面需要在动态库里面new QApplication testdll.h ...

  5. c#生成动态库并加载

    下面这段代码生成dll文件,不能编译运行.点击项目右键,点击生成,这时会在debuge文件中生成相应的配置文件. using System; using System.Collections.Gene ...

  6. solr6.3.0升级与IK动态词库自动加载

    摘要:对于中文的搜索来说,词库系统是一个很比较重要的模块,本篇以IK分词器为例子,介绍如何让分词器从缓存或文件系统中自动按照一定频次进行加载扩展词库 Lucene.Solr或ElasticStack如 ...

  7. elasticsearch中ik词库配置远程热加载

    1. 修改 IKAnalyzer.cfg.xml 配置文件中的<entry key="remote_ext_dict">http://127.0.0.1/xxx.txt ...

  8. dubbo的jmeter压测时jar包的热加载/动态加载

    在做dubbo的jmeter压测时,需要把jar包放入jmeter的lib/ext目录下,但是jmeter启动的时候会自动加载这个目录lib目录及lib/ext目录,这样启动后放入这些目录下的jar包 ...

  9. Spring Boot 如何热加载jar实现动态插件?

    一.背景 动态插件化编程是一件很酷的事情,能实现业务功能的 解耦 便于维护,另外也可以提升 可扩展性 随时可以在不停服务器的情况下扩展功能,也具有非常好的 开放性 除了自己的研发人员可以开发功能之外, ...

  10. SpringBoot开发 - 什么是热部署和热加载?devtool的原理是什么?

    在SpringBoot开发调试中,如果我每行代码的修改都需要重启启动再调试,可能比较费时间:SpringBoot团队针对此问题提供了spring-boot-devtools(简称devtools)插件 ...

随机推荐

  1. Solution -「洛谷 P5610」「YunoOI 2013」大学

    Description Link. 区间查 \(x\) 的倍数并除掉,区间查和. Solution 平衡树. 首先有个基本的想法就是按 \(a_{i}\) 开平衡树,即对于每个 \(a_{i}\) 都 ...

  2. 循序渐进介绍基于CommunityToolkit.Mvvm 和HandyControl的WPF应用端开发(6) -- 窗口控件脏数据状态IsDirty的跟踪处理

    在我们窗口新增.编辑状态下的时候,我们往往会根据是否修改过的痕迹-也就是脏数据状态进行跟踪,如果用户发生了数据修改,我们在用户退出窗口的时候,提供用户是否丢弃修改还是继续编辑,这样在一些重要录入时的时 ...

  3. Teamcenter RAC 开发之《日期控件》

    背景 Teamcenter Rac 开发客制化表单过程中,发现一个问题,就是使用日期控件JXDatePicker展示无法实现"签出"禁用控件输入 解决办法 选择使用JDateCho ...

  4. PostgreSQL学习笔记-1.基础知识:创建、删除数据库和表格

    PostgreSQL 创建数据库 PostgreSQL 创建数据库可以用以下三种方式:1.使用 CREATE DATABASE SQL 语句来创建.2.使用 createdb 命令来创建.3.使用 p ...

  5. [SUCTF 2019]CheckIn 1

    看到字符upload 就想到了文件上传漏洞 就先上传一个一句话木马试试 似乎直接上传不成功,可能是有什么过滤 再上传一个包含一句话木马的图片试试 发现提示不能是图片,这时候就不会了,在网上找了一下wp ...

  6. 【RocketMQ】RocketMQ 5.0新特性(三)- Controller模式

    在RocketMQ 5.0以前,有两种集群部署模式,分别为主从模式(Master-Slave模式)和Dledger模式. 主从模式 主从模式中分为Master和Slave两个角色,集群中可以有多个Ma ...

  7. Chromium CC渲染层工作流详解

    1. Chromium 的渲染流水线 Blink -> Paint -> Commit -> (Tiling ->) Raster -> Activate -> D ...

  8. 最新 2023.2 版本 WebStorm 永久破解教程,WebStorm 破解补丁永久激活(亲测有效)

    最近 jetbrains 官方发布了 2023.2 版本的 IDEA,之前的激活方法并不支持这个新的版本. 下面是最新的激活教程,激活步骤和之前是类似的,只是换用了不同的补丁文件. 本教程支持 Jet ...

  9. [转载]R2: 已解释和未解释的方差

    估计值的方差与总体方差之间的差异就是回归方程对方差的解释率.试举一例,如图 1,身高与体重的回归线显示身高与体重之间呈正相关,Mr. Y身高76英寸体重220磅(图 1中插图.cdr的红点),他与体重 ...

  10. 微软微服务构建框架Dapr基础入门教程

    最近学习dapr,决定将过程在此记录,也为小伙伴们学习的时候提供一份参考. Dapr的介绍这里就不多说了,大家直接可以去百度或者去官网上进行查阅,本文就简单介绍下如何使用. Dapr官方中文文档 一. ...