SyntaxHighlighter

2013年11月5日火曜日

SevenZipRuby 作成メモ 1 - 7z.dll の概要と、 7z.dll と Ruby の橋渡し

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 を用いて展開する場合は、以下のような流れになります。

  1. 7z.dll から CreateObject 関数のポインタを取得する。
  2. CreateObject 関数で、 7zip アーカイブの展開用インターフェースである IInArchive インターフェースへのポインタを取得する。
  3. IInArchive でデータを読み込むために、下記のインターフェースの派生クラスを用意する。
    IInStream
    読み込み対象のファイルにアクセスするインターフェース
    IOutStream
    アーカイブ内のデータを展開する際の書き込み先のファイルにアクセスするインターフェース
    IArchiveOpenCallback
    IInArchiveOpen 関数を呼び出す際に必要なインターフェース
    IArchiveExtractCallback
    IInArchiveExtract 関数を呼び出す際に必要なインターフェース
  4. これらのインターフェースの派生クラスのインスタンスを作成し、 IInArchiveOpen, Extract 関数を呼び出す。

CreateObject

7z.dll では、種々のアーカイブをサポートしており、それらを扱うクラスは、 IInArchive もしくは IOutArchive クラスの派生クラスとしてそれぞれ定義されています。
7z.dll でアーカイブを扱う場合、そのアーカイブの種類に合った派生クラスのインスタンスを、 7z.dll がエクスポートしている CreateObject 関数を通じて取得する必要があります。そのため、まずは CreateObject 関数を DLL から取得する必要があります。
CreateObject 関数自体は、 DLL からエクスポートされているので、以下のように取得できます。

IInArchive インターフェースの取得

続いて、取得した CreateObject から、 7zip アーカイブを展開するためのインターフェースを取得します。

CreateObject 関数の第一引数には、 CLSID_CFormat7zCLSID_CFormatZip などのような、アーカイブのファイルフォーマットを示す GUID を渡します。
一覧は CPP/7zip/Guid.txt にまとまっているので、見るとよいでしょう。 例えば CLSID_CFormat7z であれば、 {23170F69-40C1-278A-1000-000110010000} になります。

第二引数には、 IID_IInArchiveIID_IOutArchive を指定します。
今回は、展開用のインターフェースが欲しいので、 IID_IInArchive を指定します。
こちらも、GUID の値は CPP/7zip/Guid.txt に載っています。 IID_IInArchive であれば、 {23170F69-40C1-278A-0000-000600600000} です。

あとは、これで得られた archive ポインタを通じて、好きな処理をしていくことになります。
なお、 IInArchive の定義は、 CPP/7zip/Archive/IArchive.hINTERFACE_IInArchive の部分を見ると分かります。 関数の名前から、だいたい意味は分かるのではないかと思います。

IInArchive でアーカイブを読み込むために必要な諸クラス

IInStream, IOutStream, IArchiveOpenCallback, IArchiveExtractCallback の派生クラスを、すべて定義しておく必要があります。

ここでは、サンプルとして IInStream の派生クラスの定義について記述します。

IInStream のメンバの定義

IInStream は、ファイルの読み込みを抽象化したインターフェースであり、以下の関数を持っています (Read は親クラスの ISequentialInStream のメンバーです) 。
なお、以下の記述は WINAPI などの呼び出し規約を書いていないので、そのままでは使えません。実際の定義では、 STDMETHOD マクロが使われています。

このインターフェースを継承したクラスを独自に作成することで、ファイルから読み込ませることや、ネットワークソケットから読み込ませることなどが自由にできます。

Ruby との橋渡し

7z.dll と Ruby のバインディングを行うには、 7z.dll が必要とする IInStream などのインターフェースを継承したクラスを作成し、そのクラスで適切に Ruby のメソッドを呼んでやることがメインとなります。

エラー処理などを省くと、イメージとしては下記のようになります。

このようにすることで、 7z.dll の世界と Ruby の世界を結ぶことができます。

しかし、上記のようなコードは期待した通りに動きません。
これは、大きくは以下の二点の理由によります。

  • Ruby の例外に対し、安全でない。
  • IInStreamRead, 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 のメソッド呼び出し

なんて面倒なんだ。