这两天在做服务器框架的C的接口导出和C#的接入。之所以要做这么个东西是因为之前的服务器框架(atsf4g-co)已经完成了通信层面和基本设计模式的细节部分,而且基本算是最大化性能了吧。但是现在的项目的战斗引擎是从以前Unity游戏上抽象而来的,全部由C#编写。再加上最近再考虑接入实时战斗,这样就不能像之前一样用一个简单的通信方式了,必须使用一个高效并且实时性更高通信机制。需要能够处理好比较高的集中式的组播和容灾的通信方式。于是就有了把之前的C++的框架抽离出API来驱动逻辑的想法。这样也比较容易地兼顾开发成本和性能之间地权衡。

C Binding

那么抽离出框架地目的是抽象出应用底层,这个刚好是atapp做的事,而且atapp的层面对外暴露的接口数量也比较少,使用比较简单,所以索性就直接对它下手了。

让后第一步是把atapp需要使用的基本接口抽离出纯C的导出API。之所以要导出成纯C是因为,不同系统环境和编译器环境在C++层符号规则、入栈出栈顺序、内存布局、对其规则等等都不一样。这种情况要做跨平台就很是困难,然而这些在纯C的ISO里都是有明确规范的。所以最简单的方式就是导出到纯C,然后其他语言导入接口。这里的其他语言目前就只有C#,但是纯C接口的话如果想导出到lua或者其他语言的接口也不困难。

这里导出的时候有一点点小细节,那就是在Linux上的c api是默认导出的,但是在Windows里是默认不导出的,然后再加上不同编译器的导出用法不一样,所以第一步当然是统一导出标记。最终就是下面这一段

#if !defined(ATFRAME_SYMBOL_EXPORT) && defined(_MSC_VER)
#define ATFRAME_SYMBOL_EXPORT __declspec(dllexport)

#elif !defined(ATFRAME_SYMBOL_EXPORT) && defined(__GNUC__)

#ifndef __cdecl
// see https://gcc.gnu.org/onlinedocs/gcc-4.0.0/gcc/Function-Attributes.html
// Intel x86 architecture specific calling conventions
#ifdef _M_IX86
#define __cdecl __attribute__((__cdecl__))
#else
#define __cdecl
#endif
#endif

#if defined(__clang__)

#if !defined(_WIN32) && !defined(__WIN32__) && !defined(WIN32)
#define ATFRAME_SYMBOL_EXPORT __attribute__((__visibility__("default")))
#endif

#else

#if __GNUC__ >= 4
#if (defined(_WIN32) || defined(__WIN32__) || defined(WIN32)) && !defined(__CYGWIN__)
#define ATFRAME_SYMBOL_EXPORT __attribute__((__dllexport__))
#else
#define ATFRAME_SYMBOL_EXPORT __attribute__((__visibility__("default")))
#endif
#endif

#endif

#endif

#ifndef ATFRAME_SYMBOL_EXPORT
#define ATFRAME_SYMBOL_EXPORT
#endif

可以看到即便同一个编译器,在不同系统上都还不一样。上面这些基本上兼顾了主流的平台和编译器了。然后如果要导出c函数就是类似这样:

#ifdef __cplusplus
extern "C" {
#endif
    typedef union {
        void *pa;
        uintptr_t pu;
        intptr_t pi;
    } libatapp_c_context;
    ATFRAME_SYMBOL_EXPORT int32_t __cdecl libatapp_c_run(libatapp_c_context context, int32_t argc, const char **argv, void *priv_data);
    ATFRAME_SYMBOL_EXPORT int32_t __cdecl libatapp_c_reload(libatapp_c_context context);
#ifdef __cplusplus
}
#endif

这里有两个小细节,一个是那个union,这是为了内部做类型转换方便,因为要考虑到导出到其他语言的接口,所以这里的内存长度必须是一个指针长度。然后用union做数据类型转换而不是直接强转是为了消除有些编译器下的*warning*;第二就是所有的类型都使用定长的,即便在64位系统下,大多数的容器的size类型都是*size_t*或*size_type*并且等同于*uint64_t*,那我们这里也要用*uint64_t*,这也是为了跨环境的时候接口的布局是一致的。

再后面都全部是一些操作的封装了。我们大致封装的接口有这几类:

  • atapp的创建和删除
  • atapp的信息和状态函数
  • atapp获取框架层配置文件(因为我们这里用的是结构化的ini,那么为了统一配置,也可以提供基本的读取工具给逻辑)
  • atapp的基本时间类接口(目前就获取当前Unix时间戳)
  • 框架log接口(以便逻辑log导入到框架规则)
  • 通信接口(目前版本是发送数据和发送命令)
  • 控制接口(*listen*和*connect*等)
  • 各类回调函数接口(连接/断开其他服务器节点、接收到消息、发送失败等)
  • atapp的模块接口(模块用于触发reload、定时器等操作)
  • atapp的扩展功能(目前是绑定启动参数和自定义命令的回调)

目前每种类型都是只封装了会用到的一些接口,后面有特殊需求了会再添加绑定的API。

C#适配

纯C的接口封装完以后就可以导入到.net了。由于.net我并不是特别熟,所以还是碰到了一些问题的。

回调函数的生命周期问题

碰到的第一个就是回调函数生命周期的问题,因为在C#层我会封装一个高级的delegate,然而传入到C API的都是C函数。C#提供了一个方法就是用Marshal.GetFunctionPointerForDelegate把C#的delegate转换为C函数指针。比如:

public OnDisconnectedFunction OnDisconnected {
  get {
    return _on_disconnected_fn;
  }
  set {
    if (IntPtr.Zero == _native_app) {
      throw new InvalidOperationException("native object invalid");
    }

    _on_disconnected_fn = value;
    IntPtr fn = IntPtr.Zero;

    if (null != _on_disconnected_fn) {
      fn = Marshal.GetFunctionPointerForDelegate(_on_disconnected_holder = new libatapp_c_on_disconnected_fn_t(libatapp_c_on_disconnected_fn));
    }

    libatapp_c_set_on_disconnected_fn(_native_app, fn, IntPtr.Zero);
  }
}

这里就有两个对象,一个是value,保存到了*_on_disconnected_fn,另一个是libatapp_c_on_disconnected_fn(即便他是静态函数也一样)保存到了_on_disconnected_holder*。为啥有两个呢?回调函数不就一个嘛?这就是坑之一,我必须保存这个*libatapp_c_on_disconnected_fn*,否者这个函数会被.net GC回收掉,然后C API回调的时候可能会崩溃。之所以是可能是因为你不知道.net会什么时候释放掉它。

这还引出一个问题就是这类的回调函数的数据组很多,也可能是我不太会用C#的泛型,导致这些API都是手写的。感觉写的时候很危险很容易出错啊。我最多就用到了这样:

class EventCallbackRefGroup<TC> where TC: class {
  public TC native = null;
  public EventFunction cs = null;
}

static int _on_init_call(IntPtr mod, IntPtr priv_data) {
  App app = App.GetApp(priv_data);
  if (null == priv_data) {
    return (int)App.ATBUS_ERROR_TYPE.EN_ATBUS_ERR_BAD_DATA;
  }

  Module m = app.GetModule(mod);
  if (null == m) {
    return (int)App.ATBUS_ERROR_TYPE.EN_ATBUS_ERR_NOT_INITED;
  }

  if (null == m._on_init.cs) {
    return 0;
  }

  return m._on_init.cs(m);
}

private EventCallbackRefGroup<libatapp_c_module_on_init_fn_t> _on_init = new EventCallbackRefGroup<libatapp_c_module_on_init_fn_t>();
public EventFunction OnInit {
  get {
    return _on_init.cs;
  }
  set {
    if (IntPtr.Zero == _native_module) {
      throw new InvalidOperationException("Module released");
    }

    _on_init.cs = value;
    IntPtr fn;
    if (null != value) {
      fn = Marshal.GetFunctionPointerForDelegate(_on_init.native = new libatapp_c_module_on_init_fn_t(_on_init_call));
    } else {
      fn = IntPtr.Zero;
    }

    libatapp_c_module_set_on_init(_native_module, fn, Application.NativeApp);
  }
}

每个回调组有这么多代码,如果是C++的话有很方便的方法家编译器约束和减少这种代码量。因为C++的模板参数可以不止是类型,还可以是值。并且functor可以封入很多额外信息。

C#的string类型和C的char*/const char*

忘了哪里看到的C#的文档说string到const char*之类是会按ANSI编码自动转换的。但是我实测是我如果从C#层传到C层是没问题,但是反过来会发生访问内存出错。估计是传入C的是.net自己把string的数据指针直接传给C了,但是反过来它并没有按照ANSI的0来判定字符串结尾。所以后面的传出的字符串数据都得这样。

[DllImport(Message.LIBNAME, CallingConvention = CallingConvention.Cdecl)]
private static extern void libatapp_c_get_app_version(IntPtr context, out IntPtr verbuf, out ulong versz);

public string AppVersion {
  get {
    if (IntPtr.Zero == _native_app) {
      return "";
    }

    IntPtr verbuf;
    ulong bufsz;

    libatapp_c_get_app_version(_native_app, out verbuf, out bufsz);
    if (IntPtr.Zero == verbuf) {
      return "";
    }

    return Marshal.PtrToStringAnsi(verbuf, (int)bufsz);
  }
}

再就是数组的接口比较独特,要加特殊的标记,然后传入的数据不用加out或者ref,仍然是可写的,比如这样:

[DllImport(Message.LIBNAME, CallingConvention = CallingConvention.Cdecl)]
private static extern ulong libatapp_c_get_configure(IntPtr context, string path, 
                                                     [MarshalAs(UnmanagedType.LPArray)] IntPtr[] out_buf, 
[MarshalAs(UnmanagedType.LPArray)] ulong[] out_len, ulong arr_sz);

其实也可以理解,本来C#里传这类东西过去的都是引用,只是到C层丢失了长度参数而已。

非托管数据到托管数据的开销

有一个东东不能不提。那就是数据是从C过来的,如果暴露原始指针给上层并且有上层来做Marshal操作则使用成本有点高了。所以还是会转成托管数据给上层用。那么这时候就会有一次数据拷贝。这也会导致比直接使用C++的atapp多一层性能消耗。比如所有的Message的二进制传递。不过一般情况下这个占比不会特别大,只是这个开销确实存在。

.net的支持十分的诡异

最后一个问题是既然写了这个接入,我就希望能够做好跨平台。现在有了.net core、mono和.net framework。mono都是按.net framework的API做兼容的问题倒不大,只是一些特性不能用而已。但是.net core和.net framework差异就不较大了。现在的适配是按照.net standard的标准进行的,理论上是能同时跨平台兼容.net framework和.net core。但是微软有个文档说明.net standard的支持情况,https://docs.microsoft.com/zh-cn/dotnet/articles/standard/library

简单来说就是这样:

平台名称 Alias
.NET Standard netstandard 1.0 1.1 1.2 1.3 1.4 1.5 1.6 2.0
.NET 核心 netcoreapp 1.0 vNext
.NET Framework net 4.5 4.5.1 4.6 4.6.1 4.6.2 vNext 4.6.1
Mono/Xamarin 平台 vNext
通用 Windows 平台 uap 10.0 vNext
Windows win 8.0 8.1
Windows Phone wpa 8.1
Windows Phone Silverlight wp 8.0

可以看到这里面就没有交叉的部分,我也是醉了。目前我制定的是 .net standard 1.3。因为2.0版本还没有Release的SDK,1.6版本.net framework不支持。而即便是1.3,也需要.net framework 4.6以上。所以这次的适配完成和功能测试,我都是只拿了Windows上的.net framework测试的。上面列举的基本功能的都测试完成了,但是并没有试Mono或者.net core上是否可以。理论上应该可以吧,当然后续免不了接口会有些调整。

写在最后

现在基本功能和流程算是通了吧。这也是一个里程碑的阶段,后续肯定还需要调整,但是方案基本就这样没跑了。并且如果以后兴起新的技术和解决方案,atapp也可以很容易的适配过去。说不定哪天咱用go呢。

最后的最后,还是本次适配最终成果的仓库及测试代码吧,都在这里了。