自前のC++オブジェクトをjscriptで操作させたい

単純にCOMオートメーションサーバーを作成すれば事足ります。コードの再利用という点から考えてみてもそれがいいのですが・・・、一つのプログラム内で完結させたいといった時に調べてみました。

たとえば、自作の複雑なオブジェクトを持つアプリケーションがあって、jscriptなどのスクリプトでプラグインを書いてもらう、というようなケースなどでは、スクリプト環境をホストしなければなりません。

完全に自分用のメモで(^^;、 ネットなどで検索をしたり、MSDNドキュメントを拾い読みなどをまとめた備忘録です。あしからず。

で、やはりCOMのお世話にならないといけません。Visual Studio Express Editionでは全部自分でコーディングしないといけないので、退屈なコードを書かないといけないところが面倒です・・・。

手順としては、

  1. スクリプトから操作させたいオブジェクトをIDispatch を継承、もしくは、オブジェクトへのラッパークラスをIDispatch実装クラスでインプリメントする。
  2. スクリプトをホストするため(スクリプトからの様々な通知を受け取るため?)に必要なIActiveScriptSite の 実装クラスを作成

で、これらを使って、以下を順番に実行。

  1. CoCreateInstance APIでIActiveScriptのインスタンスを生成
  2. IActiteScript::SetScriptSite()へIActiveScriptSiteの実装クラスのインスタンスをnewして放り込む。
  3. IActiveScript::QueryInterface()でIActiveScriptParse インターフェイスポインタ(IID_IActiveScriptParse)を得る。
  4. IActiveScriptParse::InitNew()をコール
  5. IActiveScript::AddNamedItem()で、自前のC++オブジェクトの名前をつける。スクリプトではこの名前を使ってアクセスさせる。
  6. IActiveScriptParse::ParseScriptText()でスクリプト文を解析させる
  7. IActiveScript::SetScriptState(SCRIPTSTATE_CONNECTED)をコール
  8. IActiveScript::SetScriptState(SCRIPTSTATE_CLOSED)をコール
  9. インターフェイスポインタの後始末

という流れ。

まず、操作させたい自前のC++オブジェクトは、IDispatchインターフェイスを実装しなくてはいけません。C++とjscriptなどのスクリプトとの連携にはIDispatchインターフェイスが必ず絡んできます。

オブジェクトとIActiveScriptSiteの実装クラス

オブジェクトの仕様

  • オブジェクトは、ただ一つのパブリックプロパティ、Text を持つ。
  • オブジェクトは、ただ一つのパブリックメソッド、Warn() を持つ。
    メソッド・Warn()は、一つの文字列引数を持ち、Win32 APIの MessageBox() をコールしダイアログを表示する。
  • オブジェクトは、スクリプト内で Message という名前でグローバル変数として公開する。

上記様なオブジェクトをC++クラスで作成します。当然ながら、IDispatchを継承させます。あくまでテンプレート(スケルトン)的な備忘録が目的なので、主要な部分のみコードを起こしていくことにします。 以下のような感じです。

※コードについては参考程度のものです。無いと思いますがそのままコピペしての使用はやめた方がいいです。

#pragma comment(lib,"user32.lib")
#pragma comment(lib,"comsuppw.lib")
#pragma warning( disable : 4100 4101)

#include <windows.h>
#include <comdef.h>

class CMyObject : public IDispatch
{
private:
  ULONG m_uRef;
  PWSTR m_wStr;

public:
  CMyObject(PWSTR wStr)
       :m_uRef(1),m_wStr(NULL)
    {
      int len = lstrlenW(wStr);
      m_wStr = new WCHAR[len + 1];
      lstrcpynW(m_wStr,wStr,len+1);
    };
  ~CMyObject()
    {
      if(m_wStr)
        delete [] m_wStr;
    };

  //IUnknown
  STDMETHODIMP_(ULONG) AddRef()
    {
      return InterlockedIncrement(&m_uRef);
    };
  STDMETHODIMP_(ULONG) Release()
    {
      ULONG ulVal = InterlockedDecrement(&m_uRef);
      if(ulVal > 0)
        return ulVal;

      delete this;
      return ulVal;
    };
  STDMETHODIMP QueryInterface(REFIID riid,LPVOID *ppvOut)
    {
      if(*ppvOut)
        *ppvOut = NULL;

      if(IsEqualIID(riid,IID_IDispatch))
        *ppvOut = (IDispatch*)this;
      else if(IsEqualIID(riid,IID_IUnknown))
        *ppvOut = this;
      else
        return E_NOINTERFACE;

      AddRef();
      return S_OK;
    };

  // IDispatch
  STDMETHODIMP GetTypeInfoCount(UINT __RPC_FAR *pctinfo)
    {
      //特にインプリメントしなくても無問題なので省略。
      return E_NOTIMPL;
    }
  STDMETHODIMP GetTypeInfo(UINT iTInfo,LCID lcid,ITypeInfo __RPC_FAR *__RPC_FAR *ppTInfo)
    {
      //特にインプリメントしなくても無問題なので省略。
      return E_NOTIMPL;
    }
  //スクリプト内で使用される名前とDISPID( Invokeメソッドで必要)を関連づける。
  //lstrcmpWで文字列比較していますが、実際にはDISPIDと名前の関連付けの管理は、
  //STLのstd::mapなどのコレクションを用いた方が簡単かと思います。
  STDMETHODIMP GetIDsOfNames(REFIID riid,
                             LPOLESTR __RPC_FAR *rgszNames,
                             UINT cNames ,
                             LCID lcid,
                             DISPID __RPC_FAR *rgDispId)
    {
      HRESULT hRes = NOERROR;
      for (UINT i = 0; i < cNames; i++)
        {
          *(rgDispId + i) = DISPID_UNKNOWN;
          hr = DISP_E_MEMBERNOTFOUND;
          if (lstrcmpW(*(rgszNames + i), L"Warn") == 0)
            {
              //Warn のDISPIDは、1に定義
              *(rgDispId + i) = 1;
              hRes = S_OK;
            }
          else if(lstrcmpW(*(rgszNames + i), L"Text") == 0)
            {
              //TextのDISPIDは、2に定義
              *(rgDispId + i) = 2;
              hRes = S_OK;
            }
        }
      return hRes;
    }
  //スクリプトからは、名前ではなく上記 GetIDsOfNamesメソッドで名前と関連づけられた
  //DISPIDでコールしてくるので、DISPIDによって処理を分岐させる必要がある。
  //DISPIDをハードコードしていますが、上で書きましたが何かしらのコレクションを
  //用いて実装しないと数が増えてくると大変です。。。
  STDMETHODIMP Invoke(DISPID dispIdMember,
                      REFIID riid,
                      LCID lcid,
                      WORD wFlags,
                      DISPPARAMS FAR *pDispParams,
                      VARIANT FAR *pVarResult,
                      EXCEPINFO FAR *pExcepInfo,
                      unsigned int FAR *puArgErr)
    {
      switch(dispIdMember)
        {
        case 1: // Warn() がコールされたとき。
          if(wFlags != DISPATCH_METHOD)
            return DISP_E_MEMBERNOTFOUND;

          switch(pDispParams->cArgs)
            {
            case 0://引数なしのときは、Textプロパティーを渡す。
              MessageBoxW(NULL,m_wStr,TEXT("メッセージ"),MB_OK);
              break;
            case 1:// 引数が1つのときは、引数を文字列とみなす。
              {
                _variant_t str(pDispParams->rgvarg);
                str.ChangeType(VT_BSTR);
                MessageBoxW(NULL,str.bstrVal,TEXT("メッセージ"),MB_OK);
              }
              break;
            }
          break;

        case 2: //プロパティー Textがコールされたとき。
          switch(wFlags)
            {
            case DISPATCH_PROPERTYGET: //ゲッター
              {
                _variant_t str(m_wStr);
                str.ChangeType(VT_BSTR);
                VARIANT tmp = str.Detach();
                VariantCopy(pVarResult,&tmp);
              }
              break;
            case DISPATCH_PROPERTYPUT: //セッター
              {
                _variant_t str(pDispParams->rgvarg);
                str.ChangeType(VT_BSTR);
                int len = SysStringLen(str.bstrVal)+1;
                if(m_str)
                  delete [] m_wStr;
                m_wStr = new WCHAR[len];
                lstrcpyn(m_wStr,str.bstrVal,len);
              }
              break;
            default:
              return DISP_E_MEMBERNOTFOUND;
            }
          break;
        default:
          return DISP_E_MEMBERNOTFOUND;
        }
      return S_OK;
    }
};

IActiveScriptSiteの実装クラス

これも必要最小限のものだけ実装します。。。といいつつ、楽をしたいのでスクリプトエラーとか無視して、とりあえずは、ほとんど骨組みだけのスケルトン状態で。テスト目的では問題なしです。

#pragma comment(lib,"comsuppw.lib")
#pragma warning( disable : 4100 4101)

#include <windows.h>
#include <ActivScp.h>
#include <comdef.h>

//スクリプトで公開するオブジェクト名
#define SCRIPT_OBJECT_NAME L"Message"
/*
 IActiveScriptSite の実装。
 スクリプトで発生したエラーや、スクリプト初期化時の処理、
 スクリプトの実行状況などの情報取得などを行うクラス。
*/
class CActiveScriptSite : public IActiveScriptSite
{
private:
  ULONG m_uRef;

public:
  CActiveScriptSite();
  ~CActiveScriptSite();

  //IUnknown
  STDMETHODIMP_(ULONG) AddRef()
    {
      return InterlockedIncrement(&m_uRef);
    };
  STDMETHODIMP_(ULONG) Release()
    {
      ULONG ulVal = InterlockedDecrement(&m_uRef);
      if(ulVal > 0)
        return ulVal;

      delete this;
      return ulVal;
    };
  STDMETHODIMP QueryInterface(REFIID riid,LPVOID *ppvOut)
    {
      if(*ppvOut)
        *ppvOut = NULL;

      if(IsEqualIID(riid,IID_IActiveScriptSite))
        *ppvOut = (IActiveScriptSite*)this;
      else if(IsEqualIID(riid,IID_IUnknown))
        *ppvOut = this;
      else
        return E_NOINTERFACE;

      AddRef();
      return S_OK;
    };

  //IActiveScriptSite
  STDMETHODIMP GetLCID(LCID *plcid)
    {
      return E_NOTIMPL;
    }

  STDMETHODIMP GetItemInfo(LPCOLESTR pstrName,
                           DWORD dwReturnMask,
                           IUnknown **ppiunkItem,
                           ITypeInfo **ppti)
    {
      if((dwReturnMask & SCRIPTINFO_IUNKNOWN))
        {
          if(lstrcmpW(pstrName,SCRIPT_OBJECT_NAME)==0)
            {
              //オブジェクトのインスタンスを生成してポインタを格納
              //Release()で解放されるので delete は必要ないでしょう。
              *ppunkItem=(IUnknown*)new CMyObject(L"デフォルトテキスト");
              return S_OK;
            }
        }
      return TYPE_E_ELEMENTNOTFOUND;
    }
  STDMETHODIMP GetDocVersionString(BSTR *pbstrVersion)
    {
      //省略
      return E_NOTIMPL;
    }
  STDMETHODIMP OnScriptError(IActiveScriptError *pscripterror)
    {
      //スクリプトでエラーが起こったときの通知

      HRESULT hRes;
      if(!pscripterror)
        return E_POINTER;

      EXCEPINFO ei;
      hRes = pscripterror->GetExceptionInfo(&ei);
      //エラー内容がEXCPINFO構造体に格納されているのか、
      //それともここに自分でエラー内容を格納するのか、未調査。
      //とりあえず、ここに必要なエラー処理を行う。

      return hRes;
    }
  //スクリプトの状態が変化したときにコールされる。省略。
  STDMETHODIMP OnStateChange(SCRIPTSTATE ssScriptState)
    {
      return S_OK;
    }
  //以下、スクリプトの状態を通知。とりあえずなくても問題ないので省略します。
  STDMETHODIMP OnScriptTerminate(const VARIANT *pvarResult,const EXCEPINFO *pexcepinfo)
    {
      return S_OK;
    }
  STDMETHODIMP OnEnterScript(void)
    {
      return S_OK;
    }
  STDMETHODIMP OnLeaveScript(void)
    {
      return S_OK;
    }
};

手抜きですが、必要な実装は済みました。あとはこれを使ってスクリプトをホストするだけ。

スクリプト環境をホストする

この流れは上に書いたとおりにしていくだけで、以下のようなコーディングです。エラーチェックは必要最小限にしています。各インターフェイスメソッドの返値をチェックしてエラービットが立っていたら問答無用でクリーンナップコードブロックへ飛ばしています。

//スクリプトで公開するオブジェクト名(上記と同じもの)
#define SCRIPT_OBJECT_NAME L"Message"

//マクロ定義
#define CREATEINSTANCE(C,I,P) (SUCCEEDED(CoCreateInstance((C),NULL,CLSCTX_INPROC_SERVER,(I),(reinterpret_cast<LPVOID*>(P)))))
#define QI(X,Y,Z) ((X)->QueryInterface((Y),(reinterpret_cast<LPVOID*>(Z))))
#define RELEASE(X) {if(X){(X)->Release(); X=NULL;}}

/*
  JScriptをホストして、引数で渡されたスクリプトを実行する。
*/
HRESULT RunJScript(PWSTR pstrCode)
{
  HRESULT hRes = E_INVALIDARG;

  CLSID CLSID_Script;
  CLSIDFromProgID(L"JScript", &CLSID_Script);

  IActiveScript *pAS = NULL;
  IActiveScriptParse *pASP = NULL;
  IActiveScriptSite *pASS = NULL;

  //事前にCoInitialize APIコールが必要。
  if(CREATEINSTANCE(CLSID_Script,IID_IActiveScript,&pAS))
    {
      //インスタンス生成。pASS->Release()で開放するので、deleteする必要はないかと。
      pASS = new CActiveScriptSite();

      if(FAILED(hRes = pAS->SetScriptSite(pASS)))
        goto cleanup;

      if(FAILED(hRes = QI(pAS,IID_IActiveScriptParse,&pASP)))
        goto cleanup;

      if(FAILED(hRes = pASP->InitNew()))
        goto cleanup;

      //スクリプト側でアクセスできるようにスクリプトのグローバル変数に名前を追加。
      if(FAILED(hRes = pAS->AddNamedItem(SCRIPT_OBJECT_NAME,SCRIPTITEM_GLOBALMEMBERS | SCRIPTITEM_ISVISIBLE)))
        goto cleanup;

      //スクリプトコードのロードと解析。
      //ファイルから読み込んでこれに渡すのが一番手っ取り早いかと思います。
      //その際、ParseScriptTextに渡す文字列はUNICODE(UTF16?)にする必要があります。
      if(FAILED(hRes = pASP->ParseScriptText(pstrCode,
                                             NULL,
                                             NULL,
                                             NULL,
                                             0,
                                             0,
                                             SCRIPTTEXT_ISPERSISTENT,
                                             NULL,
                                             &epi)))
        goto cleanup;

      //スクリプト実行開始
      if(FAILED(hRes = pAS->SetScriptState(SCRIPTSTATE_CONNECTED)))
        goto cleanup;

      if(FAILED(hRes = pAS->SetScriptState(SCRIPTSTATE_CLOSED)))
        goto cleanup;
    }

cleanup:
  //後始末
  RELEASE(pASP);
  RELEASE(pAS);
  RELEASE(pASS);

  return hRes;
}

これでほとんど完成。

以下のようなスクリプトコードを上記関数に読み込ませてテスト。
うまく行きました。

/*
 テストなので、以下のコードには意味はありません。
*/
//引数あり版をコール
Message.Warn(123456789);

var text = Message.Text;
Message.Text = "テキストを変更しました。";

//引数なし版をコール
Message.Warn();

実際組んで見ると、手順さえ分かれば、意外と簡単です。
IDispach::GetIDsOfNames/IDispatch::Invokeの実装が殆どでして、オブジェクトのプロパティ、メソッドの定義と実装は、BSTR,VARIANT,LONGといった型を使わないといけない・・・ということさえクリアすれば大して難しいことはないと思います。