DoguWiki

Doghole's Personal Wiki

标签 C++/CLI 下的 Wiki

使用 JS 脚本编写运行时通达信指标

DLL 指标的存在,使得原本某些通过通达信原生指标语法实现的功能,得以借助编程语言实现,如循环结构和更复杂的分支结构。虽然使用 DLL 指标仍然存在一些限制(见:通达信DLL- 注意事项),但可以通过一些特殊手段(见:通达信 DLL 指标多参数解决方法),绕过限制。总的来说,DLL 指标优点大于缺点。

但此时就不可避免地引出了一些问题:载入 DLL 的时候,内部的指标算法是已经被编译好的,如果想要修改算法,就必须关闭程序、修改代码、重新编译再重新打开程序。这样的操作未免太麻烦。有没有一种方式,可以不用重新编译、反复打开程序,就能实现代码的修改呢?

答案是肯定的:那就是引入脚本语言——DLL 本身并不直接参与指标的计算,而是提供函数入口和脚本语言解释器。当 DLL 内的函数被调用时,首先通过脚本解释器解释动态加载的脚本,而后将函数传入的参数传递给脚本执行,最终得到的结果再返回。

通过 C++/CLI 建立 .NET Framework 混合的通达信指标 DLL中,阐述了如何通过 C++/CLI 编程使用 .NET Framework 的生态。而通过借助 .NET 的丰富生态,自然可以便捷地实现脚本解释器的功能。可供选择的脚本语言非常多,如 python、lua、ruby 等。在这里我选用对环境要求最低的一门跨平台脚本语言:Javascript。

调研工作

根据资料 Running JavaScript inside a .NET app with JavaScriptEngineSwitcher. Andrew Lock. 所述,.NET 运行 JavaScript 有两种思路,一种是通过 Node.js 构建相应的 JS 应用,由 .NET 与 Node.js 交互得到运行结果;另一种是直接在 .NET 应用程序或类库中嵌入 JS 引擎,由 .NET 应用直接解释/运行 JS 脚本。

显然,后者更适合我们的需求。根据文章对后者实现的分类,我最终选择了 Jint 作为最终实现的方式。

JS 解释器和 JS 方法实现

使用和 DebugForm 同样的方式建立JsTestClass类,并建立如下的TestJs方法:

public float[] TestJs(float[] in1, float[] in2, float[] in3)
{
    float[] result = new float[in1.Length];
    try
    {
        var engine = new Engine();
        engine.SetValue("in1", in1);
        engine.SetValue("in2", in2);
        engine.SetValue("in3", in3);
        engine.Execute(@"
            var result = [];
            for (var i = 0; i < in1.length; i++) {
                result.push((in1[i] + in2[i] + in3[i])/3);
            }
        ");
        result = engine.GetValue("result").AsArray().Select(x => (float)x.AsNumber()).ToArray();
    }
    catch (Exception ex) {}
    return result;
}

该方法接受三个float[]in1in2in3,并分别计算三个参数的平均数,返回一个新的float[]

DLL 调用 JS 方法

由于调用 DLL 方法时,通达信提供的是float型的数组指针,我们需要将其转换为float托管类型。在 TCalcFuncSet.cpp 内编写转换方法:

static array<float>^ ToManagedArray(float* pointer, int size) {
    array<float>^ result = gcnew array<float>(size);
    Marshal::Copy(IntPtr(pointer), result, 0, size);
    return result;
}

在任意自定义方法内调用托管的JsTestClass::TestJs方法,并处理返回:

void volatile TestPlugin2(int DataLen, float* pfOUT, float* pfINa, float* pfINb, float* pfINc)
{
    array<float>^ in1 = ToManagedArray(pfINa, DataLen);
    array<float>^ in2 = ToManagedArray(pfINb, DataLen);
    array<float>^ in3 = ToManagedArray(pfINc, DataLen);

    DebugForm::JsTestClass^ jsTest = gcnew DebugForm::JsTestClass();
    
    array<float>^ result = jsTest->TestJs(in1, in2, in3);
    int size = result->Length;
    Marshal::Copy(result, 0, IntPtr(pfOUT), size);

    delete in1, in2, in3, jsTest, result;
}

通达信内调用

将上面编译好的 DLL 放在指定位置下(参考dll 最终放置位置/引用),启动通达信。新建以下的指标公式:

X1:TDXDLL1(2, H, L, C);

命名为“测试公式”,即可看到调用结果:

js函数测试.png

其他

实时更改 JS 脚本

实际使用过程中,JS 脚本可以通过 TextBox 控件实时更改,而非写死在代码中,如:

public float[] TestJs(float[] in1, float[] in2, float[] in3)
{
    float[] result = new float[in1.Length];
    try
    {
        var engine = new Engine();
        engine.SetValue("in1", in1);
        engine.SetValue("in2", in2);
        engine.SetValue("in3", in3);
        engine.Execute(textBox1.Text); // textBox1 是窗体编辑器控件,可在控件内输入 js 脚本
        result = engine.GetValue("result").AsArray().Select(x => (float)x.AsNumber()).ToArray();
    }
    catch (Exception ex) {}
    return result;
}

需要注意:上文的示例中,TestJs方法所属类JsTestClass是放在一个 DLL 中的,为了方便理解,engine.Exceute直接调用了 textBox1 控件。

实际上为了避免因 窗体控件缓冲 引发的内存溢出,需要:

  • 使用 管道通信 建立一个独立的外部应用程序,控件 textBox1 属于外部应用程序,而JsTestClass所属 DLL 也需要通过管道向外部应用程序请求到 textBox1 内的脚本,经由管道返回后,再编译和执行;
  • 或者:将TestJsClass放在外部应用程序中,tdxdll 通过管道传输参数至外部应用程序,外部应用程序调用自身 textBox1 获取并执行脚本后,将执行结果通过管道返回 tdxdll,再交由通达信主程序渲染。这可能涉及到管道的同步/异步处理,本文不表。

语法高亮控件

可以使用一些控件对代码进行高亮,如 WPF 的 AvalonEdit

avalonedit.png

错误提示

对于 JS 错误(如语法错误)导致的执行失败,应捕获并给予提示,同时返回空结果,避免主程序卡死。

85 0 1,293 3 m 13 s Read All

在通达信 C++/CLI DLL 与外部应用程序之间使用管道通信

通过 C++/CLI 建立 .NET Framework 混合的通达信指标 DLL 一文中,介绍了一种使用托管类的方式,来实现调用 .NET Framework 调试窗口的一种方法,同时也说明了该种方法可能会存在内存泄漏导致主窗体和托管窗体卡顿(见窗体控件缓冲)的问题。

在前文中,虽然指出了可以通过清理缓冲区的方式来临时解决问题,但具体的内存分配大小可能会因不同通达信版本而异,分析通达信分配的内存大小也较为不便。为了解决该问题,一种较为合理的方法便是将外部窗体或外部调用类独立成一个进程,主程序和外部进程之间使用管道通信,外部进程的内存交由操作系统来管理,而非交由通达信主程序管理,避免内存泄漏。这便引出了本文。

建立外部窗体应用程序

新建 .NET Core 窗体应用程序,设计如下的和前文类似的窗体:

外部调试应用程序窗体设计

在窗体代码中设计一个独立线程,用来:1. 建立管道流;2. 接收数据和打印日志。

using System.IO.Pipes;
using System.Text;

namespace PipelineApp
{
    public partial class Form1 : Form
    {
        readonly Thread pipelineThread;
        
        public Form1()
        {
            InitializeComponent();
            pipelineThread = new Thread(() =>
            {
                byte[] bytes = new byte[10];
                char[] chars = new char[10];
                Decoder decoder = Encoding.UTF8.GetDecoder();
                while (true)
                {
                    using (NamedPipeServerStream pipeStream =
                            new("messagepipe", PipeDirection.InOut, 1,
                            PipeTransmissionMode.Message, PipeOptions.None))
                    {
                        pipeStream.WaitForConnection();
                        pipeStream.ReadMode = PipeTransmissionMode.Message;
                        int numBytes;
                        do
                        {
                            string message = "";
                            do
                            {
                                numBytes = pipeStream.Read(bytes, 0, bytes.Length);
                                int numChars = decoder.GetChars(bytes, 0, numBytes, chars, 0);
                                message += new string(chars, 0, numChars);
                            } while (!pipeStream.IsMessageComplete);

                            decoder.Reset();
                            if (!string.IsNullOrEmpty(message))
                                Invoke(new MethodInvoker(() => PrivateLog(message)));
                        } while (numBytes != 0);
                    }
                }
            })
            { IsBackground = true };
            pipelineThread.Start();

        }

        private void PrivateLog(string message)
        {
            if (textBox1.TextLength + message.Length > textBox1.MaxLength * 4)
            {
                textBox1.Text = "";
            }
            textBox1.AppendText($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.FFF}] {message}\r\n");
            textBox1.ScrollToCaret();
        }

        private void button1_Click(object sender, EventArgs e)
        {
            textBox1.Text = "";
        }
    }
}

生成项目,将 PipelineApp.exe、PipelineApp.dll 和 Pipeline.runtimeconfig.json 三个文件放在通达信目录下。这样一个外部日志窗口程序就准备好了。

编写 C++/CLI 相应方法

在''TCalcFuncSets.h''里声明如下方法:

void LogDequeue();

在''TCalcFuncSets.cpp''里声明如下的全局类和方法:

ref class GlobalObjects {
public:
    static ConcurrentQueue<String^>^ logQueue = gcnew ConcurrentQueue<String^>();
    static Thread^ thread = gcnew Thread(gcnew ThreadStart(&LogDequeue));
};

void LogDequeue() {
    NamedPipeClientStream^ clientStream;
    while (true) {
        try {
            clientStream = gcnew NamedPipeClientStream("messagepipe");
            clientStream->Connect();
            while (true) {
                String^ log;
                if (GlobalObjects::logQueue->TryDequeue(log)) {
                    array<Byte>^ buffer = Encoding::UTF8->GetBytes(log);
                    clientStream->Write(buffer, 0, buffer->Length);
                    clientStream->Flush();
                }
                else {
                    Thread::Sleep(1);
                }
            }
        }
        catch (Exception^ e) {
            // 应对外部应用程序关闭了的情况
            if (clientStream != nullptr) {
                clientStream->Close();
            }
        }
    }

}

static void Log(System::String^ message) {
    GlobalObjects::logQueue->Enqueue(message);
}

BOOL RegisterTdxFunc(PluginTCalcFuncInfo** pFun)
{
    // 启动外部程序和线程
    Process^ debugProcess = gcnew Process;
    debugProcess->StartInfo->FileName = "PipelineApp.exe";
    debugProcess->Start();
    GlobalObjects::thread->IsBackground = true;
    GlobalObjects::thread->Start();
    Log("DLL loaded.");
    if (*pFun == NULL)
    {
        (*pFun) = g_CalcFuncSets;
        return TRUE;
    }
    return FALSE;
}

最终效果

启用通达信,外部程序便被挂载,输出日志。

管道测试结果

即使关闭外部应用程序,主程序也未见卡顿,证明管道连接断开的异常已被捕获,并开始准备下一次的连接。重新手动打开外部应用程序,又能继续接收到日志。完成任务。

82 0 742 1 m 51 s Read All

通过 C++/CLI 建立 .NET Framework 混合的通达信指标 DLL

起初只是希望能在运行时调试通达信 DLL 时输出一些调试信息,直接想到的是采用一个窗体,窗体内包含输出信息的文本框,和一个清除文本框内容的按钮。但我并没有通过 C++ 开发桌面端应用的经验。否则用 Qt 应该是个不错的选择。

我对桌面端应用的开发经验大部分是关于 C# 的,首先想到的就是用 C# 来做窗体控件了,这就涉及到了 C++ 调用 C#,或者 C++ 和 C# 的混合编程。经过短时间调研,最终选择了采用 C++/CLI 来建立混合编程的方式,来构建 DLL。

这样做的好处是,我不用再额外点太多 C++ 的技能点,而且还可以享受丰富的 .NET 生态。譬如,除了设计一个调试输出窗口,还可以借助 .NET 的生态,嵌入 javascript 引擎,采用 js 写指标,让我能够在运行时动态调整指标代码。

现在开始吧。

先决条件

  • 你需要了解 C++ 语言的基础知识。
  • 在 Visual Studio 2017 及更高版本中,C++/CLI 支持是一个可选组件。 若要安装它,请从 Windows 开始菜单打开“Visual Studio 安装程序”。 确保选中“使用 C++ 的桌面开发”磁贴,并在“可选”组件部分选中“C++/CLI 支持”。

环境搭建

Win10 系统,采用 Visual Studio 2022(17.5.3),在 Visual Studio Installer 里,单个组件要勾选上:

  • .NET 桌面开发
  • 使用 C++ 的桌面开发
    • 默认选项
    • 对 v143 生成工具(最新)的 C++/CLI 支持(具体版本由你选定的 C++ 版本确定,我这里是 v143)

更多内容请参考 演练:在 Visual Studio 中编译面向 CLR 的 C++/CLI 程序

新建项目

打开 VS,创建新项目,选择“CLR 空项目(.NET Framework)”:

创建新的_clr_项目.png

新建一个名称为CLRDllTest的项目,载入后右侧是标准的 VS 开发 C++ 项目的结构,按照通达信DLL函数编程规范将指定的头文件和源文件放入对应的结构内。

新建一个 .NET Framework 类库项目DebugForm,并设计新建一个窗体类LogForm

using System;
using System.Windows.Forms;

namespace DebugForm
{

    public partial class LogForm : Form
    {
        public LogForm()
        {
            InitializeComponent();
        }

        public void Log(string message)
        {
            textBox1.AppendText($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.FFF}] {message}\r\n");
            textBox1.ScrollToCaret();
        }

        private void button1_Click(object sender, EventArgs e)
        {
            textBox1.Text = "";
        }
    }
}

窗体设计内容如下:

logform_design.png

为了方便CLRDllTest引用,我们可以调整项目DebugForm生成后事件,将其 copy 一份到CLRDllTest的项目下。此处不赘述。

CLRDllTest 引用 C# 窗体

假设CLRDllTest项目已经能引用DebugForm.dll,为了能在全局引用托管的DebugForm,在TCalcFuncSets内定义一个全局引用类:

ref class GlobalObjects {
public:
    static DebugForm::LogForm^ logForm;
};

修改导出给通达信 TCalc 的函数RegisterTdxFunc

BOOL RegisterTdxFunc(PluginTCalcFuncInfo** pFun)
{
    GlobalObjects::logForm = gcnew DebugForm::LogForm();
    GlobalObjects::logForm->Show();
    GlobalObjects::logForm->Log("DLL loaded.");
    if (*pFun == NULL)
    {
        (*pFun) = g_CalcFuncSets;
        return TRUE;
    }
    return FALSE;
}

试着在任意自定义函数内调用logForm

void TestPlugin1(int DataLen, float* pfOUT, float* pfINa, float* pfINb, float* pfINc)
{
    GlobalObjects::logForm->Log("1 号函数被调用,DataLen:" + DataLen);
    for (int i = 0; i < DataLen; i++)
        pfOUT[i] = i;
}

测试使用

编译DebugForm.dllCLRDllTest.dll,将前者放在通达信执行目录(即包含 tdxw.exe 文件的目录)下,后者放在通达信目录下的 T0002/dlls/ 下,打开通达信配置调用 DLL 指标:

{这里 H, L, C 是随便填来占位的,实际在函数内用不到}
X1:TDXDLL1(1, H, L, C);

结果:

testclrdll.png

由此便完成了 C++/CLI 模式下的 .NET Framework 混合编程。

注意事项

dll 最终放置位置/引用

DebugForm.dll 是通过 C++/CLI 托管的动态链接库,载入时,应该由主程序 EXE 载入,即放在 tdxw.exe 同目录下,由 tdxw.exe 载入,而非放在指标 DLL 目录下,否则 tdxw.exe 会找不到 DebugForm.dll,导致程序崩溃。

除非 DebugForm.dll 是通过 COM 注册的 .NET DLL,但那就是运行时动态加载了。此时,Native C++ 项目内其实是有相关 .NET DLL 的信息的(tlb, tlh)文件的,并且此时也不能使用 CLR 模式了。

窗体控件缓冲

此处调出DebugForm::LogForm,并用 TextBox 来存放日志只是一种示范,实测中,当不断调用 Log 方法将日志写入 TextBox 一段时间后,程序便卡死了。为此我调试了两天时间,什么智能指针之类的东西都用了,最后发现居然是……调试窗体的 TextBox 被输入了太多文本,内存溢出了。

这是我万万没想到的。

解决方法是定期清理缓冲区。我编写的程序中,TextBox 的MaxLength被设置为了默认值 32767,输入的日志长度刚超过这个范围时,无事发生,因为此处的MaxLength仅阻止用户输入,而不会阻止通过设置Text属性变化的文本1。但超过大约 138308 (确切值未知)的字符串长度后,程序便卡死。后来我对 TextBox 做了超过指定长度便清除的操作,就没再出现过卡死。猜测应该是被托管的窗体的 TextBox 控件的缓冲区内存溢出了,具体达到多少值便溢出,以及这个最大值由什么决定,没有深究。

所以实际上,当使用某种托管控件时,应该要考虑到该控件的性能限制,按照功能需求合理选择控件,且要考虑到控件能接受的运行时的最大缓冲,避免因内存溢出而导致程序卡死。

为了解决这种情况,如果托管的窗口有太多资源内容的,可以考虑将窗口作为一个独立的进程,通达信和额外窗口之间采用管道进行通信,而非直接调用。(见在通达信 C++/CLI DLL 与外部应用程序之间使用管道通信)。

全局引用

在示例代码中,托管窗体logForm被我放在了一个 ref class 中作为静态变量。实际上经过测试,也可以直接在最顶层声明静态/非静态的托管类型。

资源回收

采用 C++ 编程的最重要的一点,即是要注意资源的回收,否则可能会出现内存溢出的情况。

85 0 1,540 3 m 51 s Read All