NUnit を使ってみる

2011年2月6日 1 開発 , , , , , ,

恥ずかしながらユニット テストをまともに使ったことがない。

重要性は理解しているつもりで、興味もあったのだけど、仕事で触れず、趣味の開発にも取り入れることはなかった。しかし、プログラマが知るべき 97 のことのテストに関するトピックを読んで、関心度がモリモリ上昇。何かをはじめる時は、こういう気分に乗るのがよい。

それと仕事でユニット テストの採用を提案するとして、自分で使っていないものを勧められようはずもない。使うにしても簡単なスニペットを書いて、それをテストして…ではダメだ。ユニット テストを考慮していないものに取り入れるほうが現実的だろう。

というわけで、 まずは趣味のプロジェクトにユニット テストを導入してみる。プロジェクトは .NET 製なので、ツールには NUnit を選ぶ。

もくじ

レガシーコード改善ガイド

いきなりユニット テストを取り入れても、その背景にある理念を知らないと、ちょっとした問題でもつまづくだろう。そして「テストなんてはじめからなかった」などと諦めてしまいそうだ。

だいいち、テストをするといっても、どんな範囲でどのように、というのがよく分からない。何かリファレンス的なものが必要だぞ、ということで、多くの人が読んでいると思われる、レガシーコード改善ガイドを購入してみた。

まだ半分ぐらいしか読んでいないが、内容は具体的かつ実践的。とても分かりやすい。

表紙に「テストがないコードはレガシーコードだ!」なんて書いてあるけど、テスト導入が難しいことを前提としており、大半の内容は、そうした状況への対策となっている。

テストは対象の依存が少ないほど実行しやすくなる。よって対策は依存性の排除や抽象化へ向かう。例を見ると過剰に感じられるが、それも織り込み済み。全体的な複雑度の増加よりも個々の機能が独立することを目指すという。このあたりは著者も誤解の元と予想したのだろう、かなり丁寧に解説している。

実現方法としてはインターフェース、PImpl イディオムや Strategy パターンといった手法が多用される。これらの実践的なサンプルとしても有用だと思う。

難点としては「詳しくは~章を参照してください」という記述が挙げられる。紙の書籍でこれはつらい。

PC であればタブやマルチウィンドウに期待できるけれど、紙媒体ならば後述は禁物だと思う。あっても、せめて数ページの範囲に留めるべきだ。その章で完結できないなら、半端に触れないほうがいい。

あと表紙のデザインが残念。素っ気ないというか、書店で面置にしても確実に埋もれるタイプ。地味すぎて、あらかじめ評判を知っている人か手に取らないだろう。オライリーならどんな生物を持ってきただろうか?などと考えてしまった。

NUnit のインストール

テスティング フレームワークの世界では xUnit 系がメジャーのようだ。Smalltalk の SUnit を元に JUnit が生み出され、様々な言語に移植されているとのこと。今回、利用する NUnit もそのひとつ。これは .NET 用のツールとなる。

まず、ダウンロード ページからインストーラーを入手する。現時点の最新版は 2.5.9。複数のファイルが公開されているけれど、これらの内、拡張子が .msi のものをダウンロードする。

入手したものは Windows インストーラ形式なので、そのまま実行すればセットアップが開始される。特に迷うことなくインストールできるだろう。

テスト プロジェクトの作成

NUnit を使用するには、テスト用プロジェクトを作成する必要がある。このプロジェクトの概要は以下のようになる。

  • nunit.framework.dll を参照している
  • テスト用メソッドを公開 ( アクセス指定子が public ) している
  • プロジェクト形式は EXE と DLL のどちらでもよい

プロジェクトは専用のものを作成する。

Conditional 属性を利用して、デバッグ版だけテスト メソッドを有効にすれば、リリース用プロジェクトにテストを書くのもありに思える。しかしnunit.framework.dll 依存が気になるし、internal までなら設定だけで外部プロジェクトから参照できる ( リフレクションを使えば private もいける )。

また、テストだけで固まっていたほうが管理しやすい。プロジェクトの参照を見ればテスト対象が一望できるし、何がテストされているかも集約されている。NUnit の設定ファイルやテスト データの置き場所も、専用プロジェクトにまとめるのがよいだろう。

以上を踏まえつつ、テスト用プロジェクトを作成してみる。

まず、Visual Studio でテスト対象を含むソリューションを開き、プロジェクトを追加する。

名前を「テスト対象 + Tests」にして、出力の種類はクラス ライブラリを選ぶ。複数のプロジェクトに対するテストを集約するなら「ソリューション名 + Tests」にすると分かりやすい。

NUnit 自身のテスト用モジュールも、このようになっている。名前からテスト対象が分かったほうがよいし、単独で実行することはないから DLL でよい、ということなのだろう。今回作成するものは、Owl というライブラリに対するテストなので、OwlTests という名前にした。

プロジェクトを作成したら、nunit.framework.dll への参照を追加する。NUnit をインストールしているなら、.NET アセンブリがシステムに登録されているはず。

次に、テストしたいプロジェクトの参照を追加する。同じソリューション内なら、プロジェクトをそのまま参照すればよい。

テスト プロジェクトから対象の internal 部分にアクセスしたい場合は、特別な設定が必要になる。テスト対象を Owl、テスト プロジェクトが OwlTests とした場合、Owl の Assemblyinfo.cs へ以下の記述を加える。

[assembly: InternalsVisibleTo( "OwlTests" )]

これで Owl の internal 部分が OwlTests に公開される。いわゆるフレンド アセンブリである。この状態でビルドすると、生成された DLL が NUnit から利用できる形式になる。

テスト コード

まず、テスト用クラスを作成する。

名前空間は「テスト プロジェクト名.テスト対象クラスの名前空間」にしておくと分類しやすい。クラス名は「テスト対象のクラス名 + Test」にする。

NUnit とテスト対象を using で参照し、テスト クラスであることを示すために TestFixture 属性を付ける。クラスとテスト メソッドのアクセス指定子は NUnit に公開する必要があるのでpublic にする。

Owl.Id3 にある MpegAudioFrameHeader というクラスをテストする場合、以下のような実装になるだろう。

using System;
using NUnit.Framework;
using Owl.Id3;

namespace OwlTests.Id3
{
    /// <summary>
    /// MpegAudioFrameHeader のテストを実行します。
    /// </summary>
    [TestFixture]
    public class MpegAudioFrameHeaderTest
    {
    }
}

NUnit は多機能で、テスト コードの表現力も非常に高い。けれどはじめは Test、TestCase 属性と Assert クラスのメソッドをいくつか知っておけばよいと思う。

Test または TestCase 属性を付けたメソッドがテスト対象になる。メソッドを単体実行するなら Test、ひとつのメソッドで複数条件をテストしたいなら TestCase を利用する。

テスト メソッド内では、Assert クラスのメソッドを実行する。メソッドが実行されたとき、正常ならテスト成功、Assert が起きるなら失敗となる。例えば Assert.AreEqual() なら期待値と値が異なることをチェックし、Assert.Throws()、Assert.That() を組み合わせると例外の発生をテストできる。

一般的な NUnit の入門記事だと、Test 属性 + Assert.AreEqual() を説明することが多いけど、TestCase 属性がそれを兼ねられるので、利用機会は少ない。よってはじめは TestCase 属性を使ってみる。実際に書いてみると、以下のようになる。

/// <summary>
/// バージョン情報の読み込みをテストします。
/// </summary>
/// <param name="version">MPEG バージョンとレイヤー。</param>
/// <param name="target">バージョン情報の期待値。</param>
[TestCase( 0xFB, "1",   TestName = "MPEG 1"   )]
[TestCase( 0xF3, "2",   TestName = "MPEG 2"   )]
[TestCase( 0xE3, "2.5", TestName = "MPEG 2.5" )]
public void ReadMpegVersion( byte version, string target )
{
    var expected = MpegVersion.None;
    switch( target )
    {
    case "1":   expected = MpegVersion.Mpeg1;   break;
    case "2":   expected = MpegVersion.Mpeg2;   break;
    case "2.5": expected = MpegVersion.Mpeg2_5; break;
    }

    var h = new MpegAudioFrameHeader( new byte[ 4 ] { 0xFF, version, 0x00, 0x00 } );
    Assert.AreEqual( expected, h.Version, "バージョン" );
}

TestCase 属性のパラメーターは可変長で対象となるメソッドの引数に対応している。また、属性は複数同時に定義できる。この仕組みを利用することで、単一メソッドによる複数条件テストを書ける。

条件ごとに名前を付けたいなら、TestName パラメーターを指定する。ここに書いた名前は NUnit 上にそのまま表示される。テスト用パラメータと区別するために、記述する位置は末尾にするとよいだろう。

あと、この例では引数をわざわざ文字列にして、メソッド内で MpegVersion 列挙体に変換しているのだが、これには理由がある。MpegVersion 列挙体は Owl.dll で internal として定義されているため、OwlTests.dll の public メソッドの引数に指定できない。

internal もテスト可能といっても、アクセス指定の仕組みは通常どおりになる。案外、はまりそうなので、あえて例に挙げてみた。

Assert.AreEqual() は、第一引数に期待値、第二へ評価する値、第三を Assert が発生した時のメッセージにする。第一、第二引数の内容が一致するならテスト成功、それ以外は Assert になる。

次は例外についてのテストを書いてみる。

/// <summary>
/// 同期ヘッダが見つからなかった時の振る舞いをテストします。
/// </summary>
[Test]
public void FailSyncHeader()
{
    var ex = Assert.Throws< ArgumentException >(
       () =>
       {
           var header = new MpegAudioFrameHeader( new byte[ 4 ] { 0x00, 0x00, 0x00, 0x00 } );
       } );

    Assert.That( ex.Message == "'Sync header' has no information.", "同期ヘッダ" );
}

Assert.Throws の型パラメーターに例外クラスを指定することで、引数のメソッド内で発生した例外を補足してくれる。その内容が戻り値となるので、それを Assert.That() で評価することで、例外をチェックできる。

try – catch 方式でテストする場合は try で例外の有無、catch で例外の内容を評価する必要があるけれど、この方法なら評価部分が Assert.That() だけで済む。指定された例外が発生しなかったなら Assert.Throws() の戻り値は null になるのでテストも失敗してくれる。

NUnit でテストを実行してみる

NUnit を起動して以下の手順を実行する。

  • NUnit のメイン メニューから「File」→「New Project…」を選ぶ
  • ファイル保存ダイアログが表示される
  • テスト プロジェクトのフォルダを開く
  • テストプロジェクト名.nunit という名前でファイルを保存し、ダイアログを閉じる
  • NUnit のメイン メニューから「Project」→「Add Assembly…」を選ぶ
  • ファイル選択ダイアログが表示される
  • テスト プロジェクトの bin\Debug または Release を開く
  • テスト プロジェクトの DLL を選んでダイアログを閉じる

すると NUnit の左にあるツリーに、テスト メソッドが表示される。

NUnit

これらのどれかを選んだ状態で、NUnit の右にある Run ボタンを押すとテストが実行される。テストに成功したら緑、失敗なら赤が表示される。

テストを実行

赤になった時は原因も表示されるため解決の参考になる。Assert.AreEqual() によるチェックなら期待値と実際の値、Assert に到達できず例外が発生した場合はその内容が出力される。

Visual NUnit

NUnit は対象プロジェクトがビルドされると自動的にモジュールを読み直してくれる。しかしたまに失敗する。その場合は Socket が云々というエラーが表示され再起動するまで操作を受け付けなくなったりする。

Visual Studio で開発している場合、テスト実行のために NUnit へ移るのは面倒である。Visual Studio Professional の標準テスト機能なら内部実行できるので NUnit でも同じことができればなぁと思って調べたところ Visual Nunit というツールを見つけた。

これは前に紹介した AnkhSVN と同じく拡張機能となるので、インストールすると Visual Studio に統合される。セットアップの手順は以下。

まず Visual Studio のメインメニューから「ツール」→「拡張機能マネージャー」→「オンラインギャラリー」を選ぶ。次に右上の検索欄に NUnit と入力して Visual NUnit を探す。

拡張機能マネージャー

Visual NUnit が見つかったら「ダウンロード」ボタンを押す。すると以下の確認メッセージが表示されるので「インストール」ボタンを押す。

インストール確認

インストールが完了すると拡張機能マネージャーの下部に「変更を有効にするには Microsoft Visual Studio を再起動する必要があります。」と表示されるので「今すぐ再起動」ボタンを押す。自分で再起動してもよい。

再起動メッセージ

再起動した後に NUnit を利用しているソリューションを開き、メインメニューから「表示」→「その他のウィンドウ」→「Visual NUnit」を選ぶ。

Visual NUnit の表示

すると Visual NUnit のウィンドウが表示される。自動的にテスト コードが読み取られ以下のようにリスト アップされる。

Visual NUnit

ウィンドウ内の「→」と書かれたボタンを押すとテストを実行してくれる。この記事を書いている最中に更新があり、最新版だと「→」ボタンは「Run」という表記になったようだ。

Visual NUnit でテスト実行

個別にテストを実行する場合はリストから単体を選択する。複数なら Ctrl や Shift キーを押しながら項目を選ぶ。Ctrl + A で全選択になる。

このツールを使うとテストから実行までを Visual Studio で完結できて非常に便利。惜しむらくは、GUI がリストなので NUnit のように階層選択できないことか。

WM/Picture のデータ構造

2010年10月24日 0 開発 , , ,

いま作っているメタデータ編集ライブラリで、画像データをサポートしようとしている。

ID3v2 の場合は PIC または APIC、ASF は WM/Picture がこれに該当する。ID3v2 は ID3.org にデータ構造が公開されているので、そのとおり編集すればよい。ASF の場合、Windows Media Format SDK における WM/Picture の説明を読むとデータ形式は WM_PICTURE 構造体になるそうだ。

ちなみにデータメンバは以下のように定義されている。

typedef struct _WMPicture {
  LPWSTR pwszMIMEType;
  BYTE   bPictureType;
  LPWSTR pwszDescription;
  DWORD  dwDataLen;
  BYTE * pbData;
}WM_PICTURE;

Windows Media Format SDK は ID3v2 もサポートしているためか、メンバ名やデータの並びは APIC によく似ている。しかし Windows Media Player 11 ( 以下、WMP ) で画像をつけたファイルをバイナリ エディタで開いてみたところ、WM/Picture に該当する部分は以下のようになっていた。

03 D7 14 00 00 69 00 6D 00 61 00 67 00 65 00 2F 
00 6A 00 70 00 65 00 67 00 00 00 C6 30 B9 30 C8
30 B8 30 E3 30 B1 30 C3 30 C8 30 00 00 FF D8 FF
E0 00 10 4A 46 49 46 00 01 01 ...

MIME から開始されていることを期待したが、それは 5 バイト目の 69 からで、この例では “image/jpeg” となる。その後は NULL 終端を挟んで 27 バイト名から説明文 “テストジャケット” と NULL 終端、最後は JPEG のバイナリ データになっていた。

複数の WMA ファイルを用意し、WMP の拡張タグ エディタ上で画像ファイル、種類、説明を何パターンかに分けて追加してみる。そしてバイナリ エディタで比較検証してみたところ、先頭の 5 バイトはどうやら bPictureType と dwDataLen を表しているようだ。bPictureType が BYTE で 1 バイト、dwDataLen は DWORD なので 4 バイトになる。

前述の例では、bPictureType が 3 なので WMP でいうところの「カバー ( 前面 )」、dwDataLen は 5,335 バイトになる。本当に正しいかを検証するために、以下のようなクラスを作成してみた。

/// <summary>
/// WM/Picture の内容を表します。
/// </summary>
public class WmPicture
{
    /// <summary>
    /// インスタンスを初期化します。
    /// </summary>
    /// <param name="bytes">WM/Picture の内容を格納したバイト配列。</param>
    public WmPicture( byte[] bytes )
    {
        // 種別とサイズ
        this.Type = bytes[ 0 ];
        var size = BitConverter.ToUInt32( bytes, 1 );

        // MIME と説明文
        var position = 5;
        this.Mime        = this.ReadString( bytes, ref position );
        this.Description = this.ReadString( bytes, ref position );

        // 画像
        this.Data = new byte[ size ];
        Array.Copy( bytes, position, this.Data, 0, size );
    }

    /// <summary>
    /// バイト配列の指定位置から、UTF16LE 文字列を NULL 終端まで読み込みます。
    /// </summary>
    /// <param name="bytes">バイト配列</param>
    /// <param name="position">読み込み開始位置。読み終えた後は NULL 終端の直後を指します。</param>
    /// <returns>読み込んだ文字列。</returns>
    private string ReadString( byte[] bytes, ref int position )
    {
        // 終端の検索 ( 終端直前の文字が後続バイトにゼロを使用する場合も考慮する )
        var index = this.BytesIndexOf( bytes, StringTerminateBytes, position );
        if( bytes[ index + StringTerminateBytes.Length ] == 0 ) { ++index; }

        var str = Encoding.Unicode.GetString( bytes, position, index - position );
        position = index + StringTerminateBytes.Length;

        return str;
    }

    /// <summary>
    /// バイト配列内から指定されたバイト配列を探します。
    /// </summary>
    /// <param name="bytes">バイト配列。</param>
    /// <param name="token">検索するバイト配列。</param>
    /// <param name="index">検索を開始する位置。</param>
    /// <returns>バイト配列の見つかった位置を示すインデックス。未検出の場合は -1。</returns>
    /// <exception cref="ArgumentNullException">bytes または token が null 参照です。</exception>
    private int BytesIndexOf( byte[] bytes, byte[] token, int index )
    {
        // 検索対象なし
        if( bytes == null || token == null ) { throw new ArgumentNullException( "\"bytes\" or \"token\" is null." ); }

        // 検索できるデータ指定ではないので未検出として扱う
        if( bytes.Length == 0 || token.Length == 0 || token.Length > ( bytes.Length - index ) ) { return -1; }

        var max = bytes.Length - ( token.Length + 1 );
        for( var i = index; i < max; ++i )
        {
            for( var j = 0; j < token.Length; ++j )
            {
                if( bytes[ i + j ] != token[ j ] )
                {
                    break;
                }
                else if( j == token.Length - 1 )
                {
                    return i;
                }
            }
        }

        return -1;
    }

    /// <summary>
    /// 画像データを示すバイト配列を取得します。
    /// </summary>
    public byte[] Data { get; private set; }

    /// <summary>
    /// 画像の説明を取得します。
    /// </summary>
    public string Description { get; private set; }

    /// <summary>
    /// 画像の MIME ( Multipurpose Internet Mail Extension ) 情報を取得します。
    /// </summary>
    public string Mime { get; private set; }

    /// <summary>
    /// 画像の種別を取得します。
    /// </summary>
    public int Type { get; private set; }

    /// <summary>
    /// 文字列の終端を表すバイト配列。
    /// </summary>
    private static readonly byte[] StringTerminateBytes = { 0, 0 };
}

そしてバイナリ エディタを利用して WM/Picture 部分のデータを “C:\test\picture.dump” というファイルとして保存し、以下のコードを実行してみた。

var path = @"C:\test\picture.dmp";
using( var dumpFile = new FileStream( path, FileMode.Open, FileAccess.Read ) )
{
    var bytes = new byte[ dumpFile.Length ];
    dumpFile.Read( bytes, 0, bytes.Length );

    var picture = new WmPicture( bytes );
    Debug.WriteLine( String.Format( "Type = {0}", picture.Type        ) );
    Debug.WriteLine( String.Format( "MIME = {0}", picture.Mime        ) );
    Debug.WriteLine( String.Format( "Desc = {0}", picture.Description ) );

    path = @"C:\test\picture.jpg";
    using( var image = new FileStream( path, FileMode.CreateNew, FileAccess.ReadWrite ) )
    {
        image.Write( picture.Data, 0, picture.Data.Length );
    }
}

すると Visual Studio の出力ウィンドウには以下の出力が得られ、

<br />Type = 3
MIME = image/jpeg
Desc = テストジャケット

“C:\test\picture.jpg” という JEPG ファイルが保存されることを確認できた。画像としても破損は見られず、Explorer や Windows フォトギャラリーなどで表示できる。ちなみに WMP から PNG も追加してみたのだが、なぜかデータは JPEG になっていた。

今回の内容は個人的なハックによるもので、妥当性は保証できない。しかし複数パターンで検証して大丈夫だったので、ライブラリの画像取得はこれをベースに実装しようと思う。

Metadata Editor をつくる 6 – MP3 編 2

2010年9月5日 2 開発 , , , ,

Metadata Editor を自作するシリーズその 6。今回は MP3 の ID3v2 タグをより詳細に解析してゆく。ID3 Tag ページも随時更新しているけれど、あちらは参考資料として書いているので、具体的な実装などはこちらに書こうと思う。

前回からの変更点

  • ライブラリ名を Parade.Metadata から Owl に変更
    →短くドットの入らない名前にすることにした
    →池袋駅でいけふくろうをぼんやり眺めて決めた
  • ライセンスを LGPL から修正 BSD ライセンスに変更
  • ユーザー定義テキスト、言語・説明つきテキストを解析
  • 非同期化されたデータの解除処理を修正
  • 圧縮フレームに対応するため、zlib.NET のソースを取り込む

ID3v2 における文字コード

ID3v2 では、文字コード設定をもたない文字列は ISO-8859-1 となる。例えば W で開始される ID をもつ URL 系のフレームがこれに該当する。テキスト情報フレームなどは 1 バイトの文字コード設定が定義されており、以下のように指定される。

対応バージョン 文字コード
v2.2 ~ 2.4 0 ISO-8859-1
v2.2 ~ 2.4 1 UTF-16 ( BOM 付き )
v2.4 2 UTF-16 ビッグ エンディアン
v2.4 3 UTF-8

ただし、文字コード指定が 0 の場合でも ISO-8859-1 にしないソフトウェアがある。それらはシステム標準のエンコードを使用しているようだ。日本語の Windows ならば Windows-31J が設定される。そのため、厳密に ISO-8859-1 として扱うと文字化けすることになる。

この問題への対処としては、文字コードを自力で判定する、文字列を表示してユーザーに化けない文字コードを選ばせるなどの方法が考えられるが、どちらの方法もイマイチである。前者はタグ程度の長さでは精度に期待できず、後者は開発が大変なうえ、ユーザビリティを著しく損ねる。

きっと、ISO-8859-1 にしていないソフトウェアも、このような葛藤があったのだろう。現実的な解決策は、やはりシステム標準のエンコードとして扱うことだ。というわけで、私もそうする。

文字列の読み込みは基本的に System.Text.Encoding クラスの static プロパティで、対応するエンコード名を持つものを使用すればよい。システム標準ならば Encoding.Default である。UTF-16 ( BOM 付き ) の場合は BOM を判定する必要があるので、以下のような処理となる。

/// <summary>
/// BOM 付きの UTF-16 文字列を表すバイト配列を、文字列に変換します。
/// </summary>
/// <param name="value">バイト配列。</param>
/// <param name="index">バイト配列の変換を開始する位置。</param>
/// <param name="count">変換をおこなうバイト数。</param>
/// <returns>文字列。</returns>
string GetStringUtf16WithBom( byte[] bytes, int index, int count )
{
    // BOM 分 + 1 文字に満たないなら、空文字として扱う
    if( bytes == null || bytes.Length < 3 ) { return String.Empty; }

    // 適切な BOM が設定されていたら、後続バイトを文字列化する。
    if( bytes[ index ] == 0xFE && bytes[ index + 1 ] == 0xFF )
    {
        return Encoding.BigEndianUnicode.GetString( bytes, index + 2, count );
    }
    else if( bytes[ index ] == 0xFF && bytes[ index + 1 ] == 0xFE )
    {
        return Encoding.Unicode.GetString( bytes, index + 2, count );
    }
    else
    {
        return String.Empty;
    }
}

文字列の終端

ID3v2 では、文字列が $00 または $00 00 で終端されることになっている。ただし id3v2.3.0 の仕様の表現は以下のようになっている。

If the textstring is followed by a termination ($00 (00)) all the following information should be ignored and not be displayed.

終端が含まれていた場合は以降の情報を無視して表示しない、ということなので、終端がタグの終わりと等しいことは保証されないようだ。部分更新のために、余白を入れるソフトウェアがあるかもしれない。そこで、読み取ったデータをすべて文字列化するのではなく、仕様に従って終端の直前までをデータとして扱うことにする。

以下のように終端を探して、Encoding.GetString() ではその直前までを読み取り範囲として指定する。

/// <summary>
/// 指定されたバイト配列から文字列終端 ( ワイド文字なら $00 00、それ以外は $00 ) を探します。
/// </summary>
/// <param name="value">バイト配列。</param>
/// <param name="index">検索を開始する位置。</param>
/// <param name="count">検索の終了位置。</param>
/// <param name="isWideChar">バイト配列がワイド文字なら true。それ以外は false。</param>
/// <returns>成功時は終端位置。それ以外は -1。</returns>
int FindStringTerminate( byte[] value, int index, int count, bool isWideChar )
{
    var max = ( index + count < value.Length ? count : value.Length );
    for( var i = index; i < max; ++i )
    {
        if( value[ i ] == 0x00 && ( !isWideChar || ( i + 1 < value.Length && value[ i + 1 ] == 0x00 ) ) )
        {
            return i;
        }
    }

    return -1;
}

あまりにもベタな検索だが、タグ情報は最大でも数十文字ぐらいと考えられるので、十分だと思われる。ちなみに ID3v2 でワイド文字になるエンコードは UTF-16 系である。

圧縮フレーム

フレーム ヘッダに圧縮フラグが立っている場合、そのフレームは zlib による圧縮がおこなわれている。これはいわゆる ZIP 圧縮のことなのだが、zlib を名指ししている点に注意が必要である。

C# でフレームの圧縮・展開を考えた場合、.NET Framework 2.0 から追加された System.IO.Compression.GZipStream を利用できそうだが、このクラスは RFC-1952 に基づいて実装されている。一方、zlib は RFC-1951 である。1951 は Deflate、1952 は gzip であり、両者のバイナリ互換は保証されていない。

そこで今回は zlib の .NET 移植となる zlib.NET を利用することにした。

このライブラリは BSD スタイルのライセンスで提供されており、ソースの言語は C#、モジュールは zlib.net.dll となる。はじめはモジュールを参照するか、依存を最小にするために動的ロード ( モジュールを読めたときだけ圧縮フレームをサポート ) するつもりだったのだが、ソースそのものを流用することに決めた。

現在は zlib のソースへ一切、手を加えずに、そのまま Owl に取り込んでいる。そのため、zlib.NET の public クラスが Owl クライアントに見えてしまっているのだが、いずれ internal にするかもしれないので、Owl を修正 BSD ライセンスに変更した。LGPL の場合、ソース再配布時に LGPL が強制されるので BSD ライセンスと混在できない。

圧縮されたフレームの展開は以下のような処理となる。

/// <summary>
/// 圧縮されたデータを復元します。
/// </summary>
/// <param name="src">圧縮されたデータへのストリーム。</param>
/// <param name="dest">復元されたデータを書き込むストリーム</param>
void UnZip( Stream src, Stream dest )
{
    var reader = new ZInputStream( src );
    var buffer = new byte[ 1024 ];

    while( true )
    {
        var length = reader.read( buffer, 0, buffer.Length );
        if( length <= 0 ) { break; }

        dest.Write( buffer, 0, length );
    }
}

src にあたるストリームは、圧縮されたデータの始点を指した状態の Stream か、データがバイト配列として取得済みならば、それで初期化した MemoryStream を指定する。dest が展開後のストリームとなるので、これは空の MemoryStream を指定することになるだろう。

今回の成果物

今回の成果物を以下に公開。ライセンスは 修正 BSDライセンスとする。

プロジェクトは Visual C# 2010 Express で作成。ビルドをおこなうと Owl.dll と TestApp.exe というコンソールアプリケーションが生成される。現時点の機能は以下のようになる。

  • ASF ( WMA や WMV ) のタグ編集、メタデータをテキストにダンプ
  • ID3v1 ~ 2.4 のメタデータをテキストにダンプ

今後の予定

ID3v1 ~ v2 のダンプを強化しつつ、エディタを実装してゆく。NUnit によるユニットテスト、サンプルを GUI つきにすることを検討中。