SevenZipRuby 作成メモ 1 - 7z.dll の概要と、 7z.dll と Ruby の橋渡し
これから何回か、 SevenZipRuby を作成する際に悩んだことなどをメモしていこうと思います。
7z.dll のバージョンは 9.20 に基づいていますが、 7z.dll の作者の Igor さんによると、 9.30 でもこのインターフェースは使えるそうです。
なお、念のために断っておきますが、「7z.exe を呼んだらいいんじゃない?」というのはごもっともなのですが、 Ruby 拡張機能を作ること自体が目的なので、いろいろ面倒なことをしています。(DLL を呼ばないとできないこともありますし)
今回は、 7z.dll の概要と、 7z.dll と Ruby のバインディング部分を書くにあたって注意しなければならないことについて書いておきます。
結論としては、 7z.dll を呼ぶ以上、下記の二点を考慮しなければならず、面倒だ、という話です。
- Ruby の例外機構 (setjmp, longjmp) を考慮した C++ のコーディング
- 7z.dll が裏で生成する別スレッドを考慮した Ruby のメソッド呼び出し
7z.dll の仕様
今回の gem ライブラリでは、 7z.dll を内部的に呼ぼうと思ったので、 7z.dll の仕様を調べる必要がありました。
7z.dll の仕様は、断片的な情報しか見つかりませんでしたが、 7-Zip の FAQ の How can I add support for 7z archives to my application? に書かれているように、 7-Zip ソースコード中の Client7z.cpp
を見るのが楽そうです。
以下では、私が SevenZipRuby を実装する際に調べたことをまとめておきます。
メモ書きだったものを、文体だけ変更して載せているので、あまり読みやすくないと思いますが、何かの参考になればと思います。
7zip アーカイブの展開の流れ
7zip アーカイブを 7z.dll を用いて展開する場合は、以下のような流れになります。
- 7z.dll から
CreateObject
関数のポインタを取得する。 CreateObject
関数で、 7zip アーカイブの展開用インターフェースであるIInArchive
インターフェースへのポインタを取得する。IInArchive
でデータを読み込むために、下記のインターフェースの派生クラスを用意する。IInStream
- 読み込み対象のファイルにアクセスするインターフェース
IOutStream
- アーカイブ内のデータを展開する際の書き込み先のファイルにアクセスするインターフェース
IArchiveOpenCallback
IInArchive
のOpen
関数を呼び出す際に必要なインターフェースIArchiveExtractCallback
IInArchive
のExtract
関数を呼び出す際に必要なインターフェース
- これらのインターフェースの派生クラスのインスタンスを作成し、
IInArchive
のOpen
,Extract
関数を呼び出す。
CreateObject
7z.dll では、種々のアーカイブをサポートしており、それらを扱うクラスは、 IInArchive
もしくは IOutArchive
クラスの派生クラスとしてそれぞれ定義されています。
7z.dll でアーカイブを扱う場合、そのアーカイブの種類に合った派生クラスのインスタンスを、 7z.dll がエクスポートしている CreateObject
関数を通じて取得する必要があります。そのため、まずは CreateObject
関数を DLL から取得する必要があります。
CreateObject
関数自体は、 DLL からエクスポートされているので、以下のように取得できます。
1 2 3 4 5 6 7 8 | typedef UINT32 (WINAPI * CreateObjectFunc)( const GUID *clsID, const GUID *interfaceID, void **outObject); CreateObjectFunc CreateObject; HANDLE SevenZipHandle = LoadLibrary( "7z.dll" ); CreateObject = (CreateObjectFunc)GetProcAddress(SevenZipHandle, "CreateObject" ); |
IInArchive
インターフェースの取得
続いて、取得した CreateObject
から、 7zip アーカイブを展開するためのインターフェースを取得します。
1 2 3 4 | GUID format_guid = CLSID_CFormat7z; GUID interface_guid = IID_IInArchive; IInArchive *archive; HRESULT ret = CreateObject(&format_guid, &interface_guid, reinterpret_cast < void **>(&archive)); |
CreateObject
関数の第一引数には、 CLSID_CFormat7z
や CLSID_CFormatZip
などのような、アーカイブのファイルフォーマットを示す GUID を渡します。
一覧は CPP/7zip/Guid.txt
にまとまっているので、見るとよいでしょう。
例えば CLSID_CFormat7z
であれば、 {23170F69-40C1-278A-1000-000110010000}
になります。
第二引数には、 IID_IInArchive
か IID_IOutArchive
を指定します。
今回は、展開用のインターフェースが欲しいので、 IID_IInArchive
を指定します。
こちらも、GUID の値は CPP/7zip/Guid.txt
に載っています。
IID_IInArchive
であれば、 {23170F69-40C1-278A-0000-000600600000}
です。
あとは、これで得られた archive
ポインタを通じて、好きな処理をしていくことになります。
なお、 IInArchive
の定義は、 CPP/7zip/Archive/IArchive.h
の INTERFACE_IInArchive
の部分を見ると分かります。
関数の名前から、だいたい意味は分かるのではないかと思います。
IInArchive
でアーカイブを読み込むために必要な諸クラス
IInStream
, IOutStream
, IArchiveOpenCallback
, IArchiveExtractCallback
の派生クラスを、すべて定義しておく必要があります。
ここでは、サンプルとして IInStream
の派生クラスの定義について記述します。
IInStream
のメンバの定義
IInStream
は、ファイルの読み込みを抽象化したインターフェースであり、以下の関数を持っています (Read
は親クラスの ISequentialInStream
のメンバーです) 。
なお、以下の記述は WINAPI
などの呼び出し規約を書いていないので、そのままでは使えません。実際の定義では、 STDMETHOD
マクロが使われています。
1 2 3 4 5 6 7 8 9 10 11 | class IInStream { public : // データを size バイト読み込む。 // 読めたデータは data が指すバッファに格納する。 // 格納したデータのバイト数を *processedSize に格納する。 HRESULT Read( void *data, UInt32 size, UInt32 *processedSize) = 0; // C 言語の fseek と同等の処理を行う。 HRESULT Seek(Int64 offset, UInt32 seekOrigin, UInt64 *newPosition) = 0; }; |
このインターフェースを継承したクラスを独自に作成することで、ファイルから読み込ませることや、ネットワークソケットから読み込ませることなどが自由にできます。
Ruby との橋渡し
7z.dll と Ruby のバインディングを行うには、 7z.dll が必要とする IInStream
などのインターフェースを継承したクラスを作成し、そのクラスで適切に Ruby のメソッドを呼んでやることがメインとなります。
エラー処理などを省くと、イメージとしては下記のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | class RubyInStream : public IInStream { public : RubyInStream(VALUE ruby_stream); HRESULT Read( void *data, UInt32 size, UInt32 *processedSize); HRESULT Seek(Int64 offset, UInt32 seekOrigin, UInt64 *newPosition); private : VALUE m_ruby_stream; // Ruby の File インスタンスを保持するメンバ変数 }; RubyInStream::RubyInStream(VALUE ruby_stream) : m_ruby_stream(ruby_stream) { } // IInStream の Read 関数を Ruby の File#read に委譲 HRESULT RubyInStream::Read( void *data, UInt32 size, UInt32 *processedSize) { VALUE str = rb_funcall(m_ruby_stream, INTERN( "read" ), 1, ULONG2NUM(size)); if (!NIL_P(str)){ memcpy (data, RSTRING_PTR(str), RSTRING_LEN(str)); } *processedSize = (NIL_P(str) ? 0 : RSTRING_LEN(str)); return S_OK; } // IInStream の Seek 関数を Ruby の File#seek に委譲 HRESULT RubyInStream::Seek(Int64 offset, UInt32 seekOrigin, UInt64 *newPosition) { // whence is set to IO::SEEK_SET, IO::SEEK_CUR or IO::SEEK_END. VALUE whence = convert_to_ruby_whence(seekOrigin); rb_funcall(m_ruby_stream, INTERN( "seek" ), 2, LL2NUM(offset), whence); VALUE pos = rb_funcall(m_stream, INTERN( "tell" ), 0); *newPosition = NUM2ULONG(pos); return S_OK; } |
このようにすることで、 7z.dll の世界と Ruby の世界を結ぶことができます。
しかし、上記のようなコードは期待した通りに動きません。
これは、大きくは以下の二点の理由によります。
- Ruby の例外に対し、安全でない。
IInStream
のRead
,Seek
が、別スレッドで呼ばれることがある。
Ruby (MRI) は C で実装されており、 Ruby の例外などの実装には setjmp
, longjmp
を使っています。
この関数によるジャンプは、 C++ のデストラクタ呼び出しを保証しないので、混在させて使うことができません。
例えば、 RubyInStream::Read
の中で Ruby の例外が発生すると、 Read
を呼び出した関数で定義されたローカル変数のデストラクタなどは、呼ばれないままにスタックの巻き戻しが発生します。これは容易に 7z.dll のクラッシュを発生させます。
二点目については、 7z.dll と Ruby の実装が深く関わっています。
7z.dll はマルチスレッドで動作するように設計されており、 Read
は複数のスレッドから呼ばれることがあります。
7z.dll 側で同期をとった上で呼ばれているので、同時に複数のスレッドから Read
が呼ばれることはないのですが、 Ruby の実装の制約上、 GVL という Mutex を取得していないスレッドが、 Ruby の関数を呼ぶことは禁止されています。
そのため、 7z.dll が生成した別スレッドから Read
が呼ばれると、そのスレッドは GVL Mutex を取得していないので、 Ruby の関数を呼んではいけません。もし呼んでしまうと、 Ruby が Segmentation Fault で死ぬことになります。
というわけで、 7z.dll を Ruby から使えるようにするためには、以下の二点を考慮した設計にしなければなりません。
- Ruby の例外機構 (setjmp, longjmp) を考慮した C++ のコーディング
- 7z.dll が裏で生成する別スレッドを考慮した Ruby のメソッド呼び出し
なんて面倒なんだ。