C++ 回调接口设计和二进制兼容详细

目录
  • 1、疑问
  • 2、二进制兼容
  • 3、编译环境
  • 4、动态链接库
  • 5、C++ 风格,虚函数接口例子
  • 6、进一步讨论二进制兼容
  • 7、C 风格的回调,如何做二进制兼容

1、疑问

我们在开发一个视频编辑 SDK。SDK 的回调接口设计成 C 风格,结构中放着一些函数指针

struct SKYMEDIA_API SkyEncodingCallback final {
    // PS: 为达到完全的二进制兼容,这里还应该有个 structSize 的字段。见最后一小节
    void *userData = nullptr;
    bool (*shouldBeCancelled)(void *userData) = nullptr;
    void (*onProgress)(void *userData, double currentTime, double totalTime) = nullptr;
    void (*onFinish)(void *userData) = nullptr;
    void (*onError)(void *userData, SkyError error) = nullptr;
};

bool exportVideo(const char *filePath, const SkyEncodingParams &params, const SkyEncodingCallback &callback);

有同事乍一看,会有疑问,既然对外接口是 C++,为什么不直接使用 C++ 的虚函数?

struct SkyEncodingCallback {
    virtual ~SkyEncodingCallback() {}
    virtual bool shouldBeCancelled() = nullptr;
    virtual void onProgress(double currentTime, double totalTime) = nullptr;
    virtual void onFinish() = nullptr;
    virtual void onError(SkyError error) = nullptr;
};

bool exportVideo(const char *filePath, const SkyEncodingParams &params, SkyEncodingCallback *callback);

使用 C 风格的回调设计,主要考虑两个原因

  • 更容易做到库接口的二进制兼容。
  • 更容易跟 C 对应,方便绑定到各种不同的语言实现。(比如 Flutter 的封装会使用 ffi 直接调用 C)

这里不讨论语言绑定,只讨论接口的二进制兼容。

2、二进制兼容

编译好的 C/C++ 库,会提供一些头文件和动态连接库(或者静态库)。主程序(或其他库)使用头文件调用接口,之后去链接库(动态或静态链接)。

假如主程序在编译时,看到的头文件,跟库代码不匹配,就会可能产生了兼容问题。为方便描述,我们假设

  • 主程序为 skyeditor.exe
  • 库为 skymedia.dll
  • 库的头文件为 skymedia.h

有些人会奇怪,既然 skymedia.hskymedia.dll 是一起提供的,自然会匹配。怎么可能出现头文件跟库不一致呢?

3、编译环境

首先注意到,skymedia.dll skyeditor.exe 是分开编译的。库的开发者跟主程序的开发者有可能会不同,或者编译时间上会错开。

于是就可能出现,编译 skymedia.dll skyeditor.exe 所用到的编译器和编译选项不一致。

比如 skymedia.dll 用了编译器 A 预先编译,而编译 skyeditor.exe 时用了编译器 B。同一个标准库类,比如 std::string,虽然是相同的名字,但编译器 A 和编译器 B,自带 std::string 的实现却有可能不同。假如 skymedia.h 出现了一些 STL 的类,就算 skymedia.h 源码完全一样,但在编译 skymedia.dll 和 编译 skyeditor.exe 时,编码器对头文件本身的解释却会有不同。

于是在编译 skyeditor.exe 时,看到的头文件 skymedia.h,就跟 skymedia.dll 不匹配了。

C++ 并没有规定一致的二进制标准。对标准库,以及某些 C++ 语法的支持,不同的编译器是可以不同的。有时就算是相同名字的编译器,只是升级了版本,编译出来的二进制布局有可能不同。C++ 所谓的跨平台,只是源码上的跨平台,并不是二进制级别的跨平台。

假如幸运的话,不同编译器编译出来的链接符号不一样,在链接阶段能即时发现问题。但假如链接符号一致,但二进制布局不一致,到执行阶段才会出问题,就难以发现了。

另外就算是编译器和标准库完全一致,因编译选项不同也有可能引起不匹配。比如

struct Test {
    int a;
    int b;
#ifdef CONFIG_DEBUG
    int64_t debugTimestamp;
#endif
};

假如编译 skymedia.dll 和编译 skyeditor.exe 时,对宏 CONFIG_DEBUG 的定义不同。也会引起头文件和库不匹配。

将编译器和编译选项,统称编译环境。因编译环境的不同,就有可能产生二进制兼容问题。

4、动态链接库

现在假设编译器和编译选项,在编译 skymedia.dll skyeditor.exe 时完全一样,仍然有可能产生不兼容。

就是 skymedia.dll 动态升级了。

比如 skyeditor.exe 现在编译好了,已发布了出去。skymedia.dll 出现了 bug,或者更新了功能,需要让用户单独下载更新 skymedia.dll。

或者 skyeditor.exe 同时依赖了 skymedia.dll 和 plugin.dll。而 plugin.dll 也依赖了 skymedia.dll。但 skyeditor.exe 和 plugin.dll 所用到的 skymedia.dll 的版本不一致。于是就可能出现 plugin.dll 所用的 skymedia.dll 版本,被 skymedia.exe 无意中被覆盖掉了。

一个程序依赖的组件越多,独立开发的团队就越多,也就越难以协调同步每个团队所用的库(以及版本)。能预先发现版本不一致自然最好,但有时明明规定好开发准则,但还是可能出现失误,不一致就偷偷溜进来了。

动态库跟静态不同,动态库并不用强制 skyeditor.exe 重新编译,也可以单独更新。于是 skyeditor.exe 在编译时,看到的 skymedia.h 头文件,跟新版本的 skymedia.dll 有可能不同。

假设在更新 skymedia.dll 时,修改了 skymedia.h 的结构。就可能引起了二进制兼容问题。

单独更新了动态库,也有可能产生二进制兼容问题。

5、C++ 风格,虚函数接口例子

现在我们来实际分析一下代码。假如旧版 skymedia.dll 接口使用虚函数,会产生什么问题。类似这样子

// old skymedia.h
struct SkyCallback {
    virtual ~SkyCallback() {}
    virtual void callback0() = 0;
};

// old skymedia.dll
void sky_dosomthing(SkyCallback* callback) {
    // 做一些事情
    callback->callback0();
    // 做一些事情
}

skymedia.exe 在编译时候,所用到的是旧版 skymedia.dll,调用如下

class MyCallback : public SkyCallback {
    virtual ~MyCallback() {}
    virtual void callback0() {
        // 做一些事情
    }

    virtual void onKeyboard() {
        // 做一些事情
    }
};

MyCallback* callback = new MyCallback();
// 做一些事情
void sky_dosomthing(SkyCallback* callback);

现在更新了 skymedia.dll,新版本的 SkyCallback 添加了一个接口

// skymedia.h
struct SkyCallback {
    virtual ~SkyCallback() {}
    virtual void callback0() = 0;
    virtual void callback1() = 0; // 新加
};

// skymedia.dll
void sky_dosomthing(SkyCallback* callback) {
    // 做一些事情
    callback->callback0();
    // 做一些事情
    callback->callback1();
}

注意 skymedia.exe 这时并没有被重新编译(因为只单独更新了 dll),但它动态链接了新的 sky_dosomthing。于是就出现了用旧的 MyCallback 去调用新版本的 sky_dosomthing。而新版本的 sky_dosomthing 代码中,又调用了 MyCallback callback1,但旧版的 MyCallback 是没有这个 callback1的。C++ 没有类似 OC 的反射,没有很好方法去动态判断 callback1 是否存在。

于是就出现问题了,调用之后,就不知执行到哪里了。假如这里的代码只偶然被执行,问题就会隐藏得很深。

PS: C++ 常见的虚函数实现,调用虚函数会查表。调用新版本的 callback1,相当于调用表格第二项(或第三项?)的函数。对于 skymedia.exe 来说,表格第二项对应于 onKeyboard。于是只是更新了 dll,可能就莫名其妙地触发了 onKeyboard了。

在这种虚函数的设计下,要完全二进制兼容,会比较麻烦。常见的做法是,SkyCallback 每加一个接口,就定义新的名字,保持 SkyCallback 接口完全不变。于是随着时间推移,要保证二进制兼容,就产生一系列的 SkyCallbackSkyCallback2SkyCallback3。用户在更新库版本后,要用新功能,也相应使用新名字的接口类。这种做法,我个人并不喜欢。

PS: 作为对比,在 C 风格的回调,如何做二进制兼容,参考最后一小节。

6、进一步讨论二进制兼容

要完全做到二进制兼容,是一件很麻烦的事情。是否值得花力气,要看具体场合。假设编译环境可控,还能做到一旦库被修改,强制使用库的所有程序都重新编译。有这样的理想环境,就不一定要达到二进制兼容。

但我们不能假设有这样理想的环境,设想一些情况

多个不同的库,同时使用了 skymedia.dll。假如 skymedia.dll 能做到二进制兼容,某个库就可以独自升级而不用跟其他团队协调。不然难以推动其他团队一起升级,所用的库就被锁死在某个版本。
发布程序后,主程序不变,让用户独立升级 skymedia.dll,比如 fix bug 或者更新功能。(某些大型程序,会使用 dll 作为插件机制。能独立升级 dll,也就能独立升级插件)
用于调试。比如只在某个测试(更只在某个用户)的机器上出现问题,但不知道崩溃在那里。这时可以本地编译一个带调试信息的本地 dll,让测试(或用户)替换掉原来的 dll。崩溃之后就有出现一些调试信息。
库的对外接口,需要仔细考虑。而库的内部实现,肯定是一起编译的,就不需要那样讲究。SkyMedia C++ API 考虑到二进制兼容,做了一些取舍,但还没有做到完全的二进制兼容(要完全做到,还是有点麻烦的),只是尽量往这目标靠近。

不出现任何 STL 的类。(比如不使用 std::string)。
impl 手法,复杂的类,内部只包括一个 void*,隐藏掉内部全部实现。
接口不使用任何实现上不标准 C++ 特性,比如虚函数,多重继承等等。(这里不标准特性,是指不同的编译器,编译出来的二进制布局可能不一致)。
有些人可能还是问,既然 C++ 的接口这样麻烦,为什么还是提供 C++ 的接口,而不是 C 的接口。

确实,有些库就算内部采用 C++ 开发,也是导出纯 C 接口。采用 C++ 接口的,主要是考虑到纯 C 的接口用起来麻烦。

比如 C++ API,可以类似这样用

SkyResource res("/helloworld/test.mp4");
SkyVideoTrack *track = timeline->appendVideoTrack();
track->appendClip(res, SkyTimeRange(0, 10));

假如是纯 C API, 就类似这样了

SkyResource *res = SkyResource_create("/helloworld/test.mp4");
SkyVideoTrack *track = SkyTimeline_appendVideoTrack(timeline);
SkyVideoTrack_appendClip(res, SkyTimeRange(0, 10));
SkyResource_release(res);

大量写这种纯 C 代码,很繁琐,也容易忘记初始化,和释放资源。

7、C 风格的回调,如何做二进制兼容

最后,作为补充,我们回到最开始的问题。类似这种 C 风格的结构,如何做二进制兼容呢?比如下面结构

struct SkyCallback {
    void *userData = nullptr;
    void (*callback0)(void *userData) = nullptr;
};

这种结构,就跟我们最开始的 SkyEncodingCallback 很像了。

要做到完全二进制兼容,最初的 SkyCallback必须稍微改一下的,预埋一个 structSize字段,初始化成结构的大小。

// old skymedia.h
struct SkyCallback {
    int structSize = sizeof(SkyCallback); // 增加这个字段
    void *userData = nullptr;
    void (*callback0)(void *userData) = nullptr;
};

// old skymedia.dll
void sky_dosomthing(SkyCallback callback) {
    if (callback.callback0) {
        callback.callback0(callback.userData);
    }
}

skyeditor.exe 这样调用

// skyeditor.exe
void my_callback0(void* userData) {
  // 做一些事情
}

SkyCallback callback;
callback.userData = xxx;
callback.callback0 = callback0;
sky_dosomthing(callback);

现在 skymedia.dll 更新版本,为保证兼容,可以写成

// new skymedia.h
struct SkyCallback {
    int structSize = sizeof(SkyCallback);
    void *userData = nullptr;
    void (*callback0)(void *userData) = nullptr;
    void (*callback1)(void *userData) = nullptr;
};

// new skymedia.dll
void sky_dosomthing(SkyCallback callback) {
    if (callback.callback0) {
        callback.callback0(callback.userData);
    }

    // 做一些事情

    // 兼容旧版本
    if (offsetof(SkyCallback, callback1) + sizeof(callback.callback1) <= callback.structSize) {
        if (callback.callback1) {
            callback.callback1(callback.userData);
        }
    }
}

注意 sky_dosomthing 中那个对 callback1 的判断。

skyeditor.exe 使用旧版本的 skymedia.dll 编译时,SkyCallback 是没有 callback1 字段的结构,structSize 的值也相应小了。于是旧版的 skyeditor.exe 调用了新的 sky_dosomthing,那个判断就不会成立, callback1 的调用就不会被触发。

structSize 放在最前面,而新加的字段 callback1 放在结构的最后。通过 structSize 可以方便地判断新增的字段是否存在。这样自然就兼容旧版本,SkyCallback` 的结构名字也不用修改。

目前 SkyEncodingCallback,还没有添加 structSize 字段。主要是目前我们二进制兼容的需求还不算紧急,但在 API 设计上,已经留了条后路,要改起来也很容易,在源码级别也是完全兼容的。假如一开始就采用 C++ 的虚函数接口,以后就难以修改了。

类似这种结构当中添加 structSize 字段的设计,在 C 接口中,还是比较常见的。比如 Win32 API,就常见这种用法。

到此这篇关于C++ 回调接口设计和二进制兼容详细的文章就介绍到这了,更多相关C++ 回调接口设计和二进制兼容内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • C++实现string存取二进制数据的方法

    本文实例讲述了C++实现string存取二进制数据的方法,分享给大家供大家参考.具体方法分析如下: 一般来说,STL的string很强大,用起来也感觉很舒服,这段时间在代码中涉及到了用string存取二进制数据的问题,这里记录一下,以供以后参考. 首先提一下STL中string的参考资料:http://www.cplusplus.com/reference/string/string/ ,不懂的朋友可以看下. 在数据传输中,二进制数据的buffer一般用系统预设的大数组进行存储,而不是STL的s

  • C++访问Redis的mset 二进制数据接口封装方案

    需求 C++中使用hiredis客户端接口访问redis: 需要使用mset一次设置多个二进制数据 以下给出三种封装实现方案: 简单拼接方案 在redis-cli中,mset的语法是这样的: 复制代码 代码如下: /opt/colin$./redis-cli mset a 11 b 22 c 333 OK 按照这样的语法拼接后,直接使用hiredis字符串接口redisCommand传递: void msetNotBinary(redisContext *c, const vector<stri

  • C++实现十进制数转换为二进制数的数学算法

    一.十进制转换为二进制的数学算法 设目标十进制数为n,用短除法一直除以2,循环这个过程并记录余数,当商为0时结束循环,余数从后往前读就是转换为的二进制数 eg: 二.代码实现 1.设计转换函数transfer // flag是位数控制器,用remainder来暂时保存每一位余数,y是转换的二进制结果.我们的目标是把流程结束后的余数倒序输出 ,比如13的二进制数1101,但是每次除以2后得到的余数都是个位数,所以我选择使用一个位数控制器flag,从初始值1开始和remainder相乘,然后迭代(每

  • C++ 十进制转换为二进制的实例代码

    题目内容:将十进制整数转换成二进制数. 输入描述:输入数据中含有不多于50个的整数n(-231<n<231). 输出描述:对于每个n,以11位的宽度右对齐输入n值,然后输出"-->",再然后输出二进制数.每个整数n的输出,独立占一行. 题目分析:将某个数从十进制转为二进制的具体方法是,该数对2取余,结果要么为1要么为0,此为该数对应二进制的末位:然后该数除以二,得到的商再次对2取余,结果为对应二进制的倒数第二位--以此类推,知道除以2的结果为0. 参考代码: 复制代码

  • C/C++读写文本文件、二进制文件的方法

    一:目的 掌握C语言文本文件读写方式: 掌握C语言二进制文件读写方式: 掌握CPP文本文件读写方式: 掌握CPP二进制文件读写方式: 二:C语言文本文件读写 1. 文本文件写入 //采用C模式对Txt进行写出 void TxtWrite_Cmode() { //准备数据 int index[50] ; double x_pos[50], y_pos[50]; for(int i = 0; i < 50; i ++ ) { index[i] = i; x_pos[i] = rand()%1000

  • 详解C++编程中对二进制文件的读写操作

    二进制文件不是以ASCII代码存放数据的,它将内存中数据存储形式不加转换地传送到磁盘文件,因此它又称为内存数据的映像文件.因为文件中的信息不是字符数据,而是字节中的二进制形式的信息,因此它又称为字节文件. 对二进制文件的操作也需要先打开文件,用完后要关闭文件.在打开时要用ios::binary指定为以二进制形式传送和存储.二进制文件除了可以作为输入文件或输出文件外,还可以是既能输入又能输出的文件.这是和ASCII文件不同的地方. 用成员函数read和write读写二进制文件 对二进制文件的读写主

  • C++ 回调接口设计和二进制兼容详细

    目录 1.疑问 2.二进制兼容 3.编译环境 4.动态链接库 5.C++ 风格,虚函数接口例子 6.进一步讨论二进制兼容 7.C 风格的回调,如何做二进制兼容 1.疑问 我们在开发一个视频编辑 SDK.SDK 的回调接口设计成 C 风格,结构中放着一些函数指针 struct SKYMEDIA_API SkyEncodingCallback final { // PS: 为达到完全的二进制兼容,这里还应该有个 structSize 的字段.见最后一小节 void *userData = nullp

  • Spring Boot接口设计防篡改、防重放攻击详解

    本示例主要内容 请求参数防止篡改攻击 基于timestamp方案,防止重放攻击 使用swagger接口文档自动生成 API接口设计 API接口由于需要供第三方服务调用,所以必须暴露到外网,并提供了具体请求地址和请求参数,为了防止被别有用心之人获取到真实请求参数后再次发起请求获取信息,需要采取很多安全机制. 需要采用https方式对第三方提供接口,数据的加密传输会更安全,即便是被破解,也需要耗费更多时间 需要有安全的后台验证机制,达到防参数篡改+防二次请求(本示例内容) 防止重放攻击必须要保证请求

  • Java按时间梯度实现异步回调接口的方法

    1. 背景 在业务处理完之后,需要调用其他系统的接口,将相应的处理结果通知给对方,若是同步请求,假如调用的系统出现异常或是宕机等事件,会导致自身业务受到影响,事务会一直阻塞,数据库连接不够用等异常现象,可以通过异步回调来防止阻塞,但异步的情况还存在一个问题,若调用一次不成功的话接下来怎么处理?这个地方就需要按时间梯度回调,比如前期按10s间隔回调,回调3次,若不成功按30s回调,回调2次,再不成功按分钟回调,依次类推--相当于给了对方系统恢复的时间,不可能一直处于异常或宕机等异常状态,若是再不成

  • 基于NodeJS开发钉钉回调接口实现AES-CBC加解密

    钉钉小程序后台接收钉钉开放平台的回调比较重要,比如通讯录变动的回调,审批流程的回调都是在业务上十分需要的.回调接口时打通钉钉平台和内部系统的重要渠道. 但是给回调的接口增加了一些障碍,它需要支持回调的服务器的接口支持AES-CBC加解密.不然无法成功注册或解析内容. 钉钉官方文档中给出了JAVA,PHP,C#的后台SDK和demo,但是却没有Node服务器的代码支持,这让占有率很高的node服务器非常尴尬,难道node就不能作为钉钉平台的回调服务器么 好在钉钉已经开放了其加密算法,可以通过加密流

  • Android中回调接口的使用介绍

    MainActivity如下: 复制代码 代码如下: package cn.testcallback; import android.os.Bundle; import android.widget.Toast; import android.app.Activity; /** * Demo描述: * Android中回调接口的使用 */ public class MainActivity extends Activity { @Override protected void onCreate(

  • 基于Restful接口调用方法总结(超详细)

    由于在实际项目中碰到的restful服务,参数都以json为准.这里我获取的接口和传入的参数都是json字符串类型.发布restful服务可参照文章 Jersey实现Restful服务(实例讲解),以下接口调用基于此服务. 基于发布的Restful服务,下面总结几种常用的调用方法. (1)Jersey API package com.restful.client; import com.fasterxml.jackson.core.JsonProcessingException; import

  • vue.js中引入vuex储存接口数据及调用的详细流程

    前言 前几天在慕课网上看到黄轶老师的高仿饿了么app视频教程,在做接口设计的时候,我在想,这个接口能不能储存下来全局调用呢?而不是走很多次接口,管理起来也麻烦. 万能的vue果然有这个功能,那就是vuex. Vuex 是一个主要应用在中大型单页应用的类似于 Flux 的数据管理架构.它主要帮我们更好地组织代码,以及把应用内的的状态保持在可维护.可理解的状态. 如果你不太理解 Vue.js 应用里的状态是什么意思的话,你可以想象一下你此前写的 Vue 组件里面的 data 字段.Vuex 把状态分

  • C# API中模型与它们的接口设计详解

    关键要点 可变模型应该具备自我验证的能力,并实现验证接口. 在共享对象时(特别是在跨线程共享时),考虑使用不可变模型. 考虑支持MVVM风格UI的单层和多层撤消. 在实现属性变更通知时避免不必要的内存分配. 不要覆盖模型的Equals和GetHashCode方法. 在传统的MVC.MVP.MVVM.Web MVC这些UI模式中,模型是一个公共元素.虽然有很多文章讨论这些架构中的视图和控制器,但几乎无一涉及模型.在本文中,我们将讨论模型本身以及相应的.NET接口. 我想先定义一些术语,这些术语在其

  • javascript 模式设计之工厂模式详细说明

    模式类型:工厂模式 模式说明:常用模式之一,用来动态创建对象 适用范围:在运行期间需要在一系列可互换的子类中进行选择的类 注意事项:接口的实现,从而使不同子类可以被同等的对待,恰当的使用工厂模式,但不要拘泥与形式,理解本质. 关键点:以 函数/类/子类 构建的选择器 本质:函数作为选择器的使用 一般使用形式: 作为独立的选择器存在: 复制代码 代码如下: function FactoryMode(index){ switch(index){ case "index1" : return

  • java 服务器接口快速开发之servlet详细教程

    Servlet简介 servlet是Server Applet的简称,翻译过来就是服务程序.好吧,这么说你可能还是不太懂,简单的讲,这个servlet是运行在服务器上的一个小程序,用来处理服务器请求的.进一步讲,我们知道,一般的网页程序,是由我们通过浏览器访问来实现的,在这个过程中,我们的浏览器发送访问请求,服务器接收请求,并对浏览器的请求作出相应的处理.这就是我们熟悉的B/S模型(浏览器-服务器模型).而servlet就是对请求作出处理的组件,运行于支持Java的应用服务器中. Servlet

随机推荐