用Napi编写nodejs Addon并调用dll

用Napi编写nodejs Addon并调用dll

1,npdejs调用C++ addon并没有先前那篇随笔那么复杂,这是一篇补充说明:说明如何使用c++项目的include头文件以及lib,dll引入到addon内使用
2,推荐在学习addon调用dll之前,先了解cpp的显示调用dll与隐式调用dll(前者是通过LoadLibrary、GetProcAddress等函数进行手动调用与解析,后者是通过配置lib、dll路径以 __declspec(dllexport)、__declspec(dllimport)方式调用)

一、使用VS编写DLL导出项目

  • 步骤1:通过Visual Studio(推荐2019及其以上)新建一个“具有导出项的(DLL)动态链接库”
  • 步骤2:编写代码

必须准守如下规则:

  • 1、所有的dll导出必须在堆空间内,包括struct属性或者class属性(详细看底下代码样例)
  • 2、必须使用dll导出的释放函数进行释放,遵循“谁申请谁释放,申请多少释放多少”
  • 3、只能使用C基础数据类型,不能使用C++类型(例如string,map,vector等)但允许自定义class,属性也必须是C基础数据类型,且准守第1条。
注:以上规则是我个人凭测试与经验得出来的,不代表官方
//DllExportTemplate.h
#ifdef DLLEXPORTTEMPLATE_EXPORTS
#define DLL_EXPORT __declspec(dllexport)
#else
#define DLL_EXPORT __declspec(dllimport)
#endif

#include "pch.h"
#include "framework.h"
#include <string>
#include <iostream>
using namespace std;

//释放指针
DLL_EXPORT void freePointer(void* any);
DLL_EXPORT void deletePointer(void* any);
// 导出常量,这里之所以使用string,是因为这个是头文件,到时候是以源码形式被包含到addon项目内,因此什么类型都无所谓。但是如果该值不是在头文件显式定义而是存在lib文件或者dll内,则必须遵守第3条
extern DLL_EXPORT string version = "1.0.0";
// 导出指针
DLL_EXPORT char* getHello();

class DLL_EXPORT People {
public:
    char* name;
    int age;
    People(char* name, int age) {
        this->name = name;
        this->age = age;
    }
    void showInfo() {
        cout << this->name << " " << this->age << endl;
    }
    char* getName() {
        return this->name;
    }
    int getAge() {
        return this->age;
    }
    void destroy() { //会出错,我也不知道为什么
        //无论是free还是delete都会出错
        delete(this->name);
        delete this;
    }
};

DLL_EXPORT People* getPeople();
// DllExportTemplate.cpp 头文件实现
#include "framework.h"
#include "DllExportTemplate.h"
#include <string>
using namespace std;
//用于释放指针,遵守第2条
DLL_EXPORT void freePointer(void* any) {
    free(any);
}
DLL_EXPORT void deletePointer(void* any) {
    delete any;
}
// 导出指针测试
DLL_EXPORT char* getHello(){
    char* hello = new char[] {"hello\0"};
    return hello;
}
//导出class指针测试
DLL_EXPORT People* getPeople() {
    //遵守第1条
    People* p = new People(new char[] {"John"}, 100);
    return p;
}

在完成上述dll代码后,正常按照“生成解决方案”生成dll即可(推荐使用C++14标准,因为其他的我没测过,目测C++17也没问题)

二、编写addon项目

之后转到nodejs的addon项目(node-gpy项目)
项目结构如下
项目结构
把刚刚dll项目的所有头文件(如果头文件引入了别的头文件,也需要)复制到项目include下(如果复制过来报错,记得安装vscode的cpp扩展并配置好,例如cl的位置,include包含目录等,具体网上有教学)
重点:
复制过来的.h文件需要在顶部加上这么一句:

//cppTemplate/include/DllExportTemplate.h
#define DLLEXPORTTEMPLATE_EXPORTS //加这一句,具体为什么不解释了,网上查吧
//理论上在binding.gyp的defines里写也行,但是我测试之后发现编译无法通过,我也不知道怎么回事(/裂开)

#ifdef DLLEXPORTTEMPLATE_EXPORTS
#define DLL_EXPORT __declspec(dllexport)
#else
#define DLL_EXPORT __declspec(dllimport)
#endif

主要代码如下:

//Main.cpp
#include "DllExportTemplate.h"
#include <iostream>
#include <napi.h>
using namespace std;

Napi::Object Initialize(Napi::Env env, Napi::Object exports){
    People* p = getPeople();
    p->showInfo();

    string name = p->getName();
    int age = p->getAge();
    char* hello = getHello(); //需要手动释放,我忘记了

    // 不能调用destroy,我也不知道为什么会错,必须一个一个释放
    // p->destroy();
    freePointer(p->name);
    deletePointer(p);

    exports.Set("version",Napi::String::New(env, version));
    exports.Set("hello",Napi::String::New(env, hello));
    exports.Set("name",Napi::String::New(env, name));
    exports.Set("age",Napi::Number::New(env, age));
    return exports;
}

NODE_API_MODULE(NODE_GYP_MODULE_NAME, Initialize)

随后配置好binding.gyp,配置的目的就是:
1)引入dll工程的头文件到当前addon项目下
2)包含dll工程产物lib文件到addon项目内,这一步只有做过c++了开发的才知道是什么意思,其含义就是把别人c++工程引入到自己项目下
3)配置当前addon一些编译细节,例如所需要的额外库,目标名称,包含文件等

{
   "targets": [{
            "target_name": "cppTemplate", //目标名称
            "cflags!": [ "-fno-exceptions" ],
            "cflags_cc!": [ "-fno-exceptions" ],
            "sources": [
                "addons/cppTemplate/Main.cpp",//这是我的需要编译源代码路径
            ],
            "include_dirs": [
                "<!@(node -p \"require('node-addon-api').include\")",
                "addons/cppTemplate/include",//将头文件引入
            ],
            "library_dirs": [
                "addons/cppTemplate/lib" //配置lib目录
            ],
            "libraries": [
                "DllExportTemplate.lib" //将lib目录内编译好的.lib文件引入当前项目
            ],
            "dependencies": [
                "<!(node -p \"require('node-addon-api').gyp\")"
            ],
            "defines": [ 'NAPI_DISABLE_CPP_EXCEPTIONS']
        }
    ]
}

之后通过gyp build命令编译即可,得到如下内容:
编译结果
主要是得到.node文件,然后将.node文件放到测试目录下,再把运行时的dll也复制到相同目录下。为了方便测试,我直接在release目录下新建test.js做测试

const e = require("./cppTemplate.node");
console.log(e)

结果:

PS ~\build\Release> node .\test.js
John 100
{ version: '1.0.0', hello: 'hello', name: 'John', age: 100 }

可能看完会头疼,我还是总结一下流程吧:

  • 1:正常使用VS编写dll,只需要遵守上面三条规则
  • 2:编写addon项目(具体网络上有很多教程,例如如何在项目中配置node-gyp,如何配置vs,如何配置环境变量,如何配置c++环境等等等(已憋死))
  • 3:配置bingding.gyp(网上都有现成的模板,直接抄我上面的也行,记得删掉注释)
  • 4:启动node-gyp编译即可

重申:32位nodejs不能调用64位addon,反之亦然

.
.
.
.

经验与问题分享

1:addon隐式加载dll时发现找不到dll

(提一嘴,如果是显示dll调用失败,可以在dll被LoadLibrary之前使用SetDllDirectory设置一下路径即可。)

一般情况下报错是Error: The specified module could not be found.,但不绝对,只能说先排除。
(这里不讨论由于路径错误以及32位64位导致的情况,这两种情况可以排查的)
这个问题首先先说一下dll被加载的路径顺序,dll加载顺序也让人比较头疼,因为它也分很多情况和模式,这里只说简单常见情况:

1、调用程序所在目录
2、系统目录(GetSystemDirectory)通常是"\Windows\System32)"
3、Windows目录(GetWindowsDirectory)通常是"\Windows"
4、当前目录(GetCurrentDirectory)
5、环境变量PATH中所有目录

问题大部分情况出现在顺序1和顺序4:
a、应用程序所在目录:

  • 我之前一直以为这个目录就是node.exe或者eletron.exe(也就是执行的应用程序)所在目录,但是经过不断测试,得出结论不是(至少对于nodejs来说不是)。可以看下图,明明dll文件与node.exe和test.js同级,但是依旧加载失败,只有当我把dll放到.node文件同级目录或者执行命令的当前目录,才能执行成功,其余情况都不成功。
    结论:这个目录指的是“.node文件所在目录”,严格来说是谁加载它,就是谁的目录,因为有时候加载的dll又加载了别的dll,这种情况大部分都会找不到dll,是因为没搞懂这个目录问题。

b、当前目录

  • 开发环境下指的是node.exe启动项目时所在的目录,也就是执行yarn electron xxx.js命令所在目录(通俗来说就是执行命令时的当前目录)。
例如项目结构为
|--testdir
   |--xx
   |--...
|--main.js
|--xx
|--...
如果在`/`下执行`yarn electron main.js`,当前目录就是`/`。
如果我到/testdir下执行`yarn electron ../main.js`,此时当前目录指的就是`/testdir`,这下应该说明白了吧
需要特别说明,在package.json内的script定义的命令,通常会自动将目录重定向到当前项目根目录下,这一点挺重要的(这是怎么做到的我也不清楚)
  • 编译之后的生产环境,如果没有使用bat文件或者其他命令文件执行cd命令,那么指的是electron.exe所在目录,因为启动程序基本上都是双击electron.exe执行(哪怕快捷方式也不会改变目录)。

因此经过上述分析,归结到一点就是,要看dll所在的目录能否被dll规则加载到,要么在.node文件同级目录下,要么在执行命令同级目录下。作为一名开发者,需要关注开发环境与生产环境的这两个目录是不是通用的,不是就要调整过来,免得出现开发不能用生产可以用或者开发能用生产不能用的这些烦人问题。
我的建议是,把.node文件与.dll文件全丢到执行目录下,一劳永逸,开发环境的时候就切换到这个目录,生产环境打包的时候就复制到根目录下。
.

2:dll显式调用与隐式调用使用场景

概念:

  • dll显示调用,顾名思义就是手动写代码,通过LoadLibrary和GetProcAddress等方式在代码里显式的调用dll,其特征是能够随意配置dll加载路径,加载不受系统加载机制影响。
  • dll隐式调用,通过将dll工程项目包含到自己项目内,利用windows系统的dll加载机制隐藏调用方式加载。其特征是通过项目配置,将dll的lib包含到工程项目内,在代码中使用__declspec(dllexport)、__declspec(dllimport)方式隐式调用。隐式调用顾名思义找不到所谓的dll加载路径,因为dll加载全凭windows自动加载。

使用场景与优劣势

  • 显式调用也一般也称为“动态调用”,也就是运行时才加载dll;隐式调用如果不使用“延迟加载”,则隐式调用一般情况下是“静态调用”。
  • 动态调用好处就是,不受加载机制影响,想调用什么路径都行,而且动态调用是用到才加载,不会一开始就占用过多内存,对于提升启动速度和流畅有影响(具体看项目大小,太小的影响几乎可以忽略)。而隐式调用是一开始就被windows加载机制从“固定目录自动进行加载”,对启动速度和内存占用有影响,而且路径不可修改。
  • 但是dll显式调用必须手动解析dll导出的函数与参数符号,较为不便;而隐式调用可以直接将对方的dll工程产物lib与dll包含到自己项目下,然后引入dll的头文件,即可实现无缝调用。

使用场景:我个人推荐,能使用显示调用就使用显示调用,一个是可以自己配置目录,另一个就是能够自己手动加载,可定制化程度很高。注:一般情况下dll显式调用表明这个dll是一个第三方的,而项目内部dll(也就是公司或者个人所有)一般都是隐式调用。显示调用不是很正式或者不是很官方。

.
.
老实说,编写nodejs的addon真心推荐先略微学习一下VS的简单工程搭建(不用深学),这样就能理解很多概念(像什么includepath,lib与dll,#define含义,加载顺序等),还有就是不推荐使用node-ffi,这玩意怎么看怎么诡异,相当于为了用dll,学了一个和dll关系不大的东西,听说这玩意停更了(总之不能受制于人,自己动手丰衣足食),其实学习成本都差不多。学习C++不仅能在nodejs方面深入理解,在很多例如Java jna,Python等都很有帮助。很多情况下使用dll除了调用底层,还有一个就是防止源码被破解,在nodebyte方案出现之前,保护源代码最好方案就是把代码写到addon内,或者把解密方式写到addon内,通过运行addon作为入口避免被破解。如果有能力,甚至可以学习一下QQ的方式,深度定制化electron(没错,最新的QQ就是一个套壳浏览器)

posted @ 2024-06-12 18:04  麦块程序猿  阅读(45)  评论(0编辑  收藏  举报