代码请访问:https://github.com/dalerkd/KD_RPC
历时9天,规划跨度3周。代码量4k+。C++语言编写。带自动测试的多线程RPC框架->的开发记录。
前言
RPC即Remote Procedure Calls,一种中间件,提供一种类似于调用本地服务的方式来调用远程服务。
已经不少这样的框架,再实现的意义有限。但一则我考虑到自己需要一个作品来锤炼一下各项技能,二者还想到了一些好主意,我选择的突破方向是
- 提高RPC调用和本地函数调用的近似度。
不需要初始化之类的过程。接口要尽量贴近C语言服务。 - 动态更新接口
API和RPC框架分离,不需要事先知道。
有了以上两个目标,我也就不再迟疑了。
以下是开发记录:
第一次规划
客户端机制
同步调用
轮循 事件栈,触发事件
1. 发送数据
2. push事件栈
3. 进入等待状态
4. 取回结果
这么一想还真不如异步。
效率上。
此外要注意的是:同步回调的参数.
?是否兼容char &i
和指针
?
因为这两者意味着数据可能会被修改到外面。
我们可以做限制,也就是服务器端的程序会做一种限制,只能使用我们的接口。
如果我们不做限制,就需要检测到这种形式,即数据是否有变动,有的话更新数据。当然这是C++的一种形式。
异步调用
轮循
一种特殊情况就是不管结果的异步,一种方法是用空指针做异步。
FindFirstFile(BYTE* strpX,bool async,callback*);
其中async
决定是否采用异步调用。
异步调用流程是:
即使用户不关心结果也照样 push 异步回调栈,这样有助于调试?这也取决于服务端是否会发送不关心结果的数据过来:
struct {
char* 回调函数
char* Buffer//存放取回数据
size_t BufferSize
}
此外我们得关心一下,如果返回内容超过Buffer的事情。如果需要多次存取怎么办?这和Recv一样。
方案有:
1. 异常
2. 返回一个长度表示存储空间不足。(系统Recv的实现)
3. 先返回长度,再申请Buffer。(PeFileEdit中数据库接口实现)
想想就像本地使用一样,那么本地使用这些函数怎么用呢?
多参数
对于多参数一个主意是,通过结构体指针传入。这样极大地方便了我们生成动态dll。
因为参数都是一个普通指针。
struct ArgvS{
unsigned char Argc Number;
}
FindFirstFile( strpX);
分别细节
客户端
原理
根据网络或者本地需求 动态生成dll接口,让用户使用api的名字变得容易,和本地调用类似:
- 调用初始化
- LoadLibrary()
- GetProcAddress()
形式
能否自动生成头文件?
难点在于参数的获取和不出错
如何精准获取参数?我觉得依靠编写DLL时撰写的头文件是合适的。
关键点在于第一个参数结构体的获取。
其他
用三参数形式调用,只是名字不同罢了。
functionName(BYTE* argv,async =true ,callback* = nullptr);
我们看第一个参数BYTE* argv
:
这就是参数列表,它指向一个结构体:
struct argc{
char a;
int b;
_IN_ BYTE* x;
int counteof(x);
_OUT_ BYTE* y;
int counteof(y);
}
每个指针放数据量的多少是为什么呢?
因为我们要传输数据过去,不放长度发过去不知道多少。
指针在远程的改变还能不能算?算-把它看做本地调用。
也就是指针能不能回传东西给我们?
在同步的情况下,如果废弃指针这种回传方式,我认为这是不OK的。
另外const char*这种形式我就不管了。反正服务器接受到的数据,如果检测到其被修改了,我们就回传这种修改。
难点-本地要做指针解析操作
本地要按照参数列表进行指针的内容的复制和发送。
另外一种已经解决的问题是:&a
。
我们不支持哈哈。因为我们使用的是参数指针,所以直接规避了这种变态形式,666。
回调与指针参数
指针的回改作用只在同步调用下有效
只有异步的时候指针参数的回改才是有效的。因为此时调用函数会立即返回。而回改本身是一个同步操作。
而如果它作为一个异步操作将会带来太多的问题,所以这样是合理的。
优点
动态性
我们的接口数据可以随时从服务器更新。兼容性
和本地调用没有任何差别。
回调的数据谁来销毁
thread()
{
p = new(len);
strcpy;
被调用者(p,len);
delete()
}
服务端
调用格式
可以与客户端一致的格式,建议如此才能保证测试方便哈:
functionName(BYTE* argv/*async =true这个参数不暴露出来*/ ,callback* = nullptr);
也可以使用普通的编译格式:
functionName(argv1,argv2 ...,/*async =true*/ ,callback* = nullptr);
两者各有益处。但没有根本差别。
依然是通过LoadLibrary()
GetProc形式获得函数调用地址。
同步调用
需要
- 检查指针内容是否被修改 (非必须)
- 返回值的接收(必须)
异步调用
需要
- 异步调用服务端指定函数使用某值
该值回被接收相当于返回值了。但是该值可以是指针
回调函数原形 :
void Function(char* p,int len);
再总结
- 同步调用返回结果的途径:
- 指针
- 直接返回值
- 异步调用返回结果的途径:
- 指针
我有点感觉这比较困难,但是我的设计很棒:)我有能力实现它。
我得估算一下任务量和优先任务级。
一个建议是使用尽量相同的机制,在开始的时候不要处理特殊情况,比如:
不要优化掉空的回调。
特殊情况后处理。
任务优先级
基本关键词
自动测试,错误处理,基本框架搭建,功能扩展。
错误处理:“最早暴露”策略。
基本框架搭建
本地客户端基本功能分为:
- 初始化器
- 建立通讯
- 修改DLL
- DLL
- 接收调用信息,打包调用信息,传输给通讯机制。
- 按照返回信息,按照不同的调用方式返回给他们。
服务端
- 启动
- 搜集接口信息
- 建立连接
- 发送接口信息
- 按照任务安排参数,调用任务相关函数
- 获取返回信息并回传
- 获取返回值 检查 指针是否改动,如果改动,回传数据
- 获取回调信息
框架层次
类分:
- 网络层:处理网络连接
- 协议层:自定义协议解析与生成
- 参数处理层:参数获取相关
- 等待机制:。
测试
分网络测试和本地测试
我们要做到远程调用和本地调用无区别。即 加入网络的测试,和直接 使用远程DLL在本地执行的过程无根本区别。
实际RPC的流程:
1. 初始化
2. LoadLibrary(),GetProcAddress()
本地直接调用服务DLL的流程:
1. LoadLibrary(),GetProcAddress()
所以要做到远程DLL服务接口和本地调用接口一致。
注意
必须分类。不想看到一堆垃圾。
一些额外的情况
版本号不匹配的情况
握手信息
服务中断的服务转移
服务器地址的动态获取
心跳包及其过滤
……
任务列表
具体设计:
实施顺序是:
1. 不带网络层的客户端直接使用服务端的DLL测试 - 定结构体格式 √
2. 假定已经有存在.h文件并能解析且导出函数已经被初始化完毕:
用代码尝试打通 “异步和同步区分与分别注册”过程封装类。
**写测试函数。如果不想一直重看这里的话。
3. “数据格式打包”“数据格式解包”过程封装类。要客户端和服务端都能用的。
**写测试函数。
共11项 4. 格式解析
**写测试函数。
4. 回调参数捕获-服务端
4. 参数修改侦测-服务端
4. 参数回写-客户端
4. 服务端初始化程序
4. 客户端初始化程序-导出函数修改
5. 完成除网络外的所有内容 并能够在单机运行。
6. 网络层
部分难题集
FreeLibrary不能退出的问题
引出了:静态全局指针的问题。发现
静态类指针在全局初始化后;
在dllmain()中做添加操作,在其他导出函数中再次获取,却发现其使用了新的指针,与全局指针不同的对象。
新探索-支持本地函数零成本迁移
对方会压多少参数进来?
对方压入的参数是指针还是变量?
DLLmain能干嘛
能够在dllmain里做所有的初始化的事情?
Yes:就放一个dll.最好是这个.
No:就放一个另一个dll中
struct中指针到底偏移几何?
你传输struct,是不是得知道它多大呀?
难道得靠服务器传?还是自己解析?
自己解析很不靠谱啊。
其中的指针,用户怎么给我们标记它们是指针呀。
即使直接解析.h,那么我们也非常难知道参数结构体中指针的偏移。
解决之道
如果是动态语言就解决问题了。
我们一则不想限制用户必须将指针放在指定偏移,二则不想麻烦用户告知我们偏移在哪里,其实麻烦一下也没有关系的。
这应该在服务器编译时搞定。
一种方法是通过指针找回去?
能否通过指针宏把SizeOf算出来?
看一下别人是怎么做的吧
我们上面遭遇的问题是由于抽象层级低导致的。
基于偏移问题。
两种解决方案:
1. 预处理
先执行一遍获取struct偏移
2. 指针宏
在定义结构体的指针处做手脚来计算偏移。
struct
{
int a;
int b;
char* p;
int p_len;
}
struct first
{
int x;
char y;
}
就是这么吊
struct second
{
}
结果
还是限制吧,限制并标记字符串。这样就能把问题解决在服务端。
还是动态语言好呀。
解决方案
反正就是得限制一下下啦,不然没有办法推测啦。指定字符串数量:
eg:数量为:3
那么就是
struct xx{
char* a;
int a_len;
char* b;
int b_len;
char* c;
int c_len;
char y;
int z;
......
}
即前面必须是三个指针参数和对应的实际长度。这样我们通过一个3
即可了解所有偏移信息。
容忍少量限制,才能更多自由。
心得者 新得也
经历了设计项目到实施,在实施中重构到最终完成的过程。
单元测试理念
依照单元测试设计的理念,本项目在设计时就完成了验证最终结果的模块。
而相关模块的确定,明确了何时结束。并且在初期引入了较为方便调试的支持,在中期加强了相关调试输出机制。在后期在提出更多需求时,对调试输出做了改善。
同样依照是单元测试的理念,本项目最终以通过全部测试为阶段性终结。
这带给我一些不一样的开发经历。
类的深入使用带来的全面益处
而类的使用,也变得更加随心所欲。
本项目是类的集合,通过设计自动管理类。
类的三大特点:继承,多态,封装。
用类的封装来隔离代码和数据。
用类的继承来提炼公共模块。
用类的多态来设计方便的共用代码。
服务端和客户端公用模块达到8个。
为了解决多线程服务的问题,对栈和队列都进行了多线程封装。
将转流代码在设计中期重构为公用代码,也是为了重用和集中管理的目的。
以上举动大幅度提升了代码的重用。
如设计指针的统一管理方案,解决了不定量指针需要释放的问题。这样的例子还有很多,经此之战后我对类的运用越来越熟练了。
对抗疲劳-引入新设计替代不好的设计的策略
人总是会累和疲劳的,我在此项目开发过程中遵照“将难题留给明天,而不是今天决定不做”的方法。来解决以上问题。
通过单独的设计验证工程,使得新技术在稳定后才进入主项目。
对待BUG的态度
我知道做为一个中型项目,BUG的出现是难以避免的,那么以下成为我在设计程序时首要考虑的问题:
1. 如何从机制上更少的出现BUG
2. 如何在出现BUG后更好定位
3. 如何在代码调整时避免引入新BUG
涉及到了流格式结构设计
经验+2;
只依赖接口的网络层
不在开始时设计网络层,因为我设计过网络层。对设计一个能用的网络层有足够信心。
而网络层和应用层不应当有关系,两者应当只依赖接口,通过设计让网络层和其他层分离彻底。
使得我可以在中后期用一个下午时间设计了基于MailSlot的网络层。
来自版本管理工具的保障
当然Github的使用,是以上成功的保证。
跨度3周,纯写代码用了8天。总共4k+行。
可改善之处
- 依赖于Windows平台。由于大量Windows函数的使用。
而我不清楚C代码库中的替代函数。 - RPC不支持类
或者说没有提供类的流化程序。而我并不明确清楚这一过程如何实施。 - 没有提供高效的网络模块和协商机制等
网络模块是RPC的核心,我偏重于本地用户体验,但是缺乏高性能网络模块的实际设计经验。