VB6(VBA)からC#製DLLを呼ぶ際のマーシャリング術

C++ などから呼べるDLL をC# で作れるDllExport というツールがあります。
この記事は、そのDLL をVB6 やVBA から呼ぶという、極めてマイナーな方法を行う際のマーシャリング方法について記述した個人的な備忘録です。

# VB6 とは
1990年代の使われていた言語で、現在はその派生形がOffice 製品のVBA で生き残っている。

特に、最終バージョンのVB6.0 では、ActiveX コンポーネントを組み合わせることでC++ などと比較して高い生産性を確保でき、開発者が確保しやすいことから日本ではVB6.0 を使った業務システムなどが大量に作られた。

継承やマルチスレッド等がないものの、クラスを使ったカプセル化などは行えるので、一応オブジェクト指向っぽい開発も行える。
[Visual Basic](https://ja.wikipedia.org/wiki/Visual_Basic)

# DllExport[^1] とは
C# でC++ などで作られたネイティブDLL のように扱えるDLL を生成できるツール

[DllExport](https://github.com/3F/DllExport/releases)

# なぜこんなことをしようと思ったのか
前職で10年以上にわたり、20世紀に開発されたVB6 製アプリのメンテをしていたが、さすがにVB6 では辛くなってきたので、マイグレーションしたかったが
- 動作しない期間を設けられない(常になんらかの機能追加やバグ修正が行われている)
- 2本のツールをメンテしたくない(携わるのは私一人だけ)

という理由で、機能追加の際にその部分だけをVB6 の代わりにC# で書けるようにして、少しずつ移行したかった。

幸い、アプリのメンテナンスをしているのは私一人だけで、開発ツールや開発手法はすべて個人に委ねられていたため、技術面以外での問題はなかった。

# なぜCOM DLL などではダメだったのか
これ以外の方法では、レジストリ登録が必要になってしまう。

対象のVB6 製アプリはEXE 1個で出来ており、少なくともイントーラーなどを使用せず、フォルダーをコピーで動作することが必須条件だった。
side-by-side なども試してみたものの、安定動作させることができなかった。


# 1.DllExportを使ってVB6から呼べるDLLを作る
### 1) クラスライブラリのプロジェクトを作成
まず、.Net Framework のクラスライブラリを作成します。
私が作成したときは、Windows XP までサポートする必要があったので.Net Framework 4 にしました。
Windows 7 以降でいいなら、.Net Framework 4.8 を選びましょう。
DllExport 自体は .Net Core もサポートしているらしいですが、まだ試したことがありません。
そのうち実験してみる予定です。

### 2) NuGet からDllExportを追加
ソリューションの再読み込みが必要なので事前にきちんとソリューションやプロジェクトを保存しておくこと


### 3) csprojファイルを開き、C#10を使えるように設定
```:DllExportSample.csproj
  <PropertyGroup>
    <LangVersion>10.0</LangVersion>
  </PropertyGroup>
```
C#10を使えるようにするのは、DllExportを実装する際、ファイル スコープ namespaceと、モジュール初期化を使えるようにするため。



### 4) VB6側から呼ぶメソッドを実装
基本形はこんな感じ
```C#:DllExport.cs
//ファイル スコープ namespace(ネストが1段減ってすっきりする)
namespace DllExportSample;

//静的クラスとして定義することで、静的メソッドしか定義できないようにします。
public static class DllExport
{
    /// <summary>
    /// モジュール初期化子(プログラムの実行時(正確にはモジュール読み込み時)に1回だけ実行される処理を書きます。
    /// </summary>
    [ModuleInitializer]
    public static void Init()
    {
        //ThreadExceptionイベントハンドラを追加
        Application.ThreadException += new System.Threading.ThreadExceptionEventHandler(Application_ThreadException);

        //UnhandledExceptionイベントハンドラを追加する
        AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException);
        //MessageBox.Show("Initialized");
    }

    //ThreadExceptionイベントハンドラ
    private static void Application_ThreadException(object sender, System.Threading.ThreadExceptionEventArgs e) =>
        //エラーメッセージを表示する
        MessageBox.Show(e.Exception.Message, "エラー");

    //UnhandledExceptionイベントハンドラ
    static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) =>
        //エラーメッセージを表示する
        Console.WriteLine("エラー: {0}", ((Exception)e.ExceptionObject).Message);

    //int
    //呼び出し規約はStdCall
    //public、staticなメソッドとして定義
    //引数の型については、bittableな型については割と自動的に対応してくれるが、必要に応じてマーシャリングの指定を行い型の違いを吸収する
    [DllExport(CallingConvention = CallingConvention.StdCall)]
    public static int FuncInt(int x, int y) => x + y + 2;
}
```
.NET FrameworkでC#9.0以降に追加された機能については、ランタイム依存でなければ利用できる[^2]
```C#:Attribute.cs
//.Net Frameworkでモジュール初期化子を使う場合は、こんな風に属性を定義してあげると使えるようになります。
namespace System.Runtime.CompilerServices;

[AttributeUsage(AttributeTargets.Method, Inherited = false)]
internal sealed class ModuleInitializerAttribute : Attribute
{
}
```

# 2.VB6側のプロジェクトを準備
### 1) DLLを呼び出すための処理を実装するため、標準EXEでプロジェクトを作成します。
特に注意する点はないので詳細割愛
今回は、FormMain.vbというフォームと、コールバック関数を実装するためのmdlMain.basという標準モジュールを追加しました。

# 3.いろいろなマーシャリングパターン[^3]
カッコ内はVB6の型です。
### 1) byte(byte)
```VisualBasic:FormMain.vb
Private Declare Function FuncByte Lib "DllExportSample.dll" (ByVal x As Byte, ByVal y As Byte) As Byte

Private Sub cmd計算Byte_Click()
On Error GoTo Error
    txt結果.Text = FuncByte(Val(txt値X.Text), Val(txt値Y.Text))
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
```

```C#:DllExport.cs
//byte
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static byte FuncByte(byte x, byte y) => (byte)((x + y) & 0xFF);
```

### 2) short(Integer)
```VisualBasic:FormMain.vb
Private Declare Function FuncShort Lib "DllExportSample.dll" (ByVal x As Integer, ByVal y As Integer) As Integer

Private Sub cmd計算Integer_Click()
On Error GoTo Error
    txt結果.Text = FuncShort(Val(txt値X.Text), Val(txt値Y.Text))
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
```

```C#:DllExport.cs
//short
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static short FuncShort(short x, short y) => (short)(x + y + 1);
```

### 3) int(Long)
```VisualBasic:FormMain.vb
Private Declare Function FuncInt Lib "DllExportSample.dll" (ByVal x As Long, ByVal y As Long) As Long

Private Sub cmd計算Long_Click()
On Error GoTo Error
    txt結果.Text = FuncInt(Val(txt値X.Text), Val(txt値Y.Text))
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
```

```C#:DllExport.cs
//int
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static int FuncInt(int x, int y) => x + y + 2;
```

### 4) float(Single)
```VisualBasic:FormMain.vb
Private Declare Function FuncFloat Lib "DllExportSample.dll" (ByVal x As Single, ByVal y As Single) As Single

Private Sub cmd計算Single_Click()
On Error GoTo Error
    txt結果2.Text = FuncFloat(Val(txt値X2.Text), Val(txt値Y2.Text))
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
```

```C#:DllExport.cs
//float
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static float FuncFloat(float x, float y) => x + y;
```

### 5) double(Double)
```VisualBasic:FormMain.vb
Private Declare Function FuncDouble Lib "DllExportSample.dll" (ByVal x As Double, ByVal y As Double) As Double

Private Sub cmd計算Double_Click()
On Error GoTo Error
    txt結果2.Text = FuncDouble(Val(txt値X2.Text), Val(txt値Y2.Text))
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
```

```C#:DllExport.cs
//double
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static double FuncDouble(double x, double y) => (x + y) * 2;
```

### 6) long(Currency)
```VisualBasic:FormMain.vb
Private Declare Function FuncLong Lib "DllExportSample.dll" (ByVal x As Currency, ByVal y As Currency) As Currency

Private Sub cmd計算Currency_Click()
On Error GoTo Error
    'Currencyは小数点4桁固定で8バイトなので、C#のlongで10000倍された整数で扱える
    txt結果2.Text = FuncLong(Val(txt値X2.Text), Val(txt値Y2.Text))
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
```

```C#:DllExport.cs
//long
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static long FuncLong(long x, long y) => (x + y) + 1;
```

### 7) bool(Boolean)
```VisualBasic:FormMain.vb
Private Declare Function FuncBool Lib "DllExportSample.dll" (ByVal x As Boolean) As Boolean

Private Sub chkBool_Click()
On Error GoTo Error
    chkBool.Caption = FuncBool(CBool(chkBool.Value))
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
```

```C#:DllExport.cs
//bool
[DllExport(CallingConvention = CallingConvention.StdCall)]
[return: MarshalAs(UnmanagedType.U1)]
public static bool FuncBool([MarshalAs(UnmanagedType.U1)] bool x) => !x;
```

### 8) string(String)[^4]
VB6 のString はBSTR だが、VB6 ではそのまま渡された場合、Unicode → ANSI に変換されてしまうため、StrPtr でString 変数のポインタを渡す。
戻り値は、同様にポインタとしてもらい、String にコピーすることで文字コードの変換を回避

```VisualBasic:FormMain.vb
Private Declare Function FuncString Lib "DllExportSample.dll" (ByVal x As Long) As Long
'ソース メモリ ブロックの内容をコピー先のメモリ ブロックにコピーし、重複するソース と宛先のメモリ ブロックをサポートします。
'VOID RtlMoveMemory(_Out_ VOID UNALIGNED *Destination, _In_ const VOID UNALIGNED *Source, _In_ SIZE_T Length);
Private Declare Sub RtlMoveMemory Lib "kernel32.dll" (ByVal Destination As Any, ByVal Source As Any, ByVal length As Long)

'新しい文字列を割り当て、渡された文字列をコピーします。BSTR SysAllocString([in, optional] const OLECHAR *psz);
Private Declare Function SysAllocString Lib "OleAut32.dll" (ByVal psz As Long) As Long

Private Sub cmdString_Click()
On Error GoTo Error
    Call MsgBox(PtrToString(FuncString(StrPtr(txt名前.Text))), vbOKOnly)
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub

Private Function PtrToString(ByRef x As Long) As String
On Error GoTo Error
    Dim L   As Long
    L = SysAllocString(x)
    
    Dim result  As String
    Call RtlMoveMemory(ByVal VarPtr(result), ByVal VarPtr(L), LenB(L))
    PtrToString = result
Exit Function
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Function
```

```C#:DllExport.cs
    //string
    [DllExport(CallingConvention = CallingConvention.StdCall)]
    [return: MarshalAs(UnmanagedType.LPWStr)]
    public static string FuncString([MarshalAs(UnmanagedType.LPWStr)] StringBuilder x) => $"{x}さん、こんにちは";
```

### 9) コールバック関数(関数ポインタ)
コールバック関数として渡すメソッドを標準モジュールに作り、AddressOf で関数ポインタを渡す。

```VisualBasic:FormMain.vb
Private Declare Sub FuncCallback Lib "DllExportSample.dll" (ByVal callback As Long, ByVal x As Long, ByVal y As Long)

Private Sub cmdCallback_Click()
On Error GoTo Error
    Call FuncCallback(AddressOf callback, 3, 4)
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
```
```VisualBasic:mdlCallback.bas
Public Function callback(ByVal x As Long, ByVal y As Long) As Long
    callback = x * y
End Function
```

```C#:DllExport.cs
//コールバック関数用デリゲート
[UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet = CharSet.Unicode)]
public delegate int CallBack(int x, int y);

//コールバック関数
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static void FuncCallback([MarshalAs(UnmanagedType.FunctionPtr)] CallBack func, int x, int y) => MessageBox.Show($"func({x}, {y})={func(x, y)}");
```

### 10) 可変長配列[^5]
```VisualBasic:FormMain.vb
Private Declare Function FuncArray Lib "DllExportSample.dll" (ByVal values As Long, ByVal length As Long) As Long

Private Sub cmd配列_Click()
On Error GoTo Error
    Dim 合計        As Long
    Dim values(9)   As Long
    Dim loopIndex   As Long
    '1,2,3,4,5,6,7,8,9,10という値の入った配列を用意
    For loopIndex = 0 To UBound(values)
        values(loopIndex) = loopIndex + 1
    Next
    '全部の合計を求める
    合計 = FuncArray(VarPtr(values(0)), UBound(values) + 1)
    
    Dim results()   As String
    'VB6では、配列が確保されていないときUbound()をすると例外が発生してしまうので、Ubound = -1になるよう配列を初期化
    results = Split(vbNullString, vbNullChar)
    'Longの配列の中身を、Stringの配列に移し替える
    For loopIndex = 0 To UBound(values)
        ReDim Preserve results(UBound(results) + 1)
        results(UBound(results)) = CStr(values(loopIndex))
    Next

    '1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10=55
    Call MsgBox(Join(results, " + ") & "=" & CStr(合計), vbOKOnly)
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
```

```C#:DllExport.cs
//可変長配列
//参考URL
//Default Marshalling for Arrays
//https://learn.microsoft.com/en-us/dotnet/framework/interop/default-marshalling-for-arrays
[DllExport(CallingConvention = CallingConvention.StdCall)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:未使用のパラメーターを削除します", Justification = "<保留中>")]
public static int FuncArray([MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 1)] int[] array, int length) =>
    //配列の数値を合計した値を返す
    array.Sum();
```

### 11) ウィンドウハンドル
```VisualBasic:FormMain.vb
Private Declare Sub FuncWindowHandle Lib "DllExportSample.dll" (ByVal handle As Long)

Private Sub cmdフォーム_Click()
On Error GoTo Error
    Call FuncWindowHandle(Me.hWnd)
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
```

```C#:DllExport.cs
//ウィンドウハンドル
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static void FuncWindowHandle(IntPtr x)
{
    var handle = Control.FromHandle(x);

    using var dialog = new FormMain();
    dialog.ShowDialog(handle);
}
```

### 12) double(Date)
Date (日付型)は、IEEE 64 bit(8byte) として格納されているのでdouble で扱える。
```VisualBasic:FormMain.vb
Private Declare Function FuncDate Lib "DllExportSample.dll" (ByVal x As Date) As Date

Private Sub cmdDate_Click()
On Error GoTo Error
    Call MsgBox("明日は" & Format$(FuncDate(Now()), "yyyy/mm/dd hh:mm:ss") & "です。", vbInformation Or vbOKOnly)
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
```

```C#:DllExport.cs
//Date
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static double FuncDate(double x) => DateTime.FromOADate(x).AddDays(1).ToOADate();
```

### 13) 構造体
省略(基本的には組み合わせなので下記URLを参考にアライメントを合わせてあげればいけるはず)
[C#でレガシーな事をする方向けのまとめ](https://qiita.com/hmuronaka/items/619f8889e36c7b5db92d)


# その他
・マルチスレッドは注意
マルチスレッド処理(または、SeriPort やHTTP 等)を行う場合は、やり取りをUI スレッド上で行うこと。

# まとめ
忘れないように書いておこう

[^1]:[C++からC# DLL を直接利用する方法](https://qiita.com/Midoliy/items/58d56e202f104ebf867a)

[^2]:[[メモ]C# 8/9の言語機能を.NET Frameworkで使う](https://qiita.com/kenichiuda/items/fada6068ea265fd6a389)

[^3]:[【Windows/C#】なるべく丁寧にDllImportを使う](https://qiita.com/mitsu_at3/items/94807ee0b3bf34ffb6b2)

[^4]:[VBAの文字列変数とVarPtr,StrPtrについて、関係性を調べた。StrPtrとVB文字列型の正体について。](https://qiita.com/yamashiroakihito/items/06d8ffd95bba60f631fd)

[^5]:[Default Marshalling for Arrays](https://learn.microsoft.com/en-us/dotnet/framework/interop/default-marshalling-for-arrays)