ページ

2010年12月9日木曜日

C#勉強中 SQL Server その3

前回からの続きで。

スキーマ定義

ファイルハッシュの取得方法と形式(文字列32文字)が決まったところで、チェック用のファイル情報を保持するDBスキーマを定義します。

必要なものは後で本体に組み込むこととして、ここではDBスキーマ定義とチェック用ファイル情報のDBへの登録に限定して調べていきましょう。プロジェクト名を「MD5List」として新規プロジェクトを作ります。余分な作業は避けたいのでコンソールアプリで作成します。

DBエクスプローラから「データ接続」→「接続の追加...」で新しいDBを作成します。DB名は「FileListMD5」で、ファイル「C:\TEMP\FileListMD5.mdf」に設定しました。で、ここのテーブル領域に二つのテーブル「DirEntries」「FileEntries」を追加します。DirEntryでファイルシステム上のディレクトリ情報(はパス名だけです)を保持し、FileEntryで個別のファイル情報を保持します。本来はファイル情報側でフルパス名を保持すれば充分なのですが、多くのファイルのパス名プレフィックス部分は同じになりますし、ファイルのチェックサムを計算する時にファイル名をフルパスで渡すのは効率が悪くなりそうなので、ディレクトリとそこに含まれるファイルを分離しています。

トラブル発生

と、スキーマを定義して保存しようとしたら、いつまで経っても完了しません。画面の再描画を繰り返しているように見えます。ちょうど、再描画コードの最後でInvalidateを発行したような動きになっています。30分経っても完了しないのでしょうがなくタスクマネージャから止めましたが、その後、VSは起動はできるのですが、プロジェクトを読み込もうとすると同様の症状になって画面表示を繰り返してしまいます。

どこかの管理データが壊れてしまったのかも知れません。再インストールしてみましょう。

上書き、修復インストールしてみました。まあ、速いとはとてもいえませんが、普通に待つ程度でプロジェクトが表示されてきました。保存途中だったDBスキーマもしっかり保存されていました。ということで余計なトラブルありましたが、作業を再開します。

O/Rマップ

スキーマ定義が終わったら、O/Rマップによって、DBにアクセスするためのオブジェクト「データコンテキスト」とDBのレコードを保持するオブジェクト「データオブジェクト」を生成します。データオブジェクトの名前はテーブル名に基づいて(自動で)生成されますが、アクセスオブジェクトの名前はクラス追加時に指定したファイル名に基づいて決められます。

プロジェクトのコンテキストメニューから「追加」⇒「クラス」で表示されるテンプレートから「LINQ to SQL」クラスを選択します。「LINQ to SQL」クラスには dbml ファイルが対応付けられ、追加時点でファイル名を指定します。このファイル名は O/R マップで生成されるDBへのアクセスオブジェクトの名前(の一部)として使用されます。今回は「MD5」としておきましょう。ファイル名を指定して追加すると O/R designer 画面が表示されます。そこにDBテーブルをD&Dするとデータオブジェクトが(追加で)生成されます。生成されたオブジェクトは、O/R designer 画面で、背景部分をクリックするとアクセスオブジェクトのプロパティが、D&Dされたテーブル部分をクリックするとそれぞれのテーブルに対応したデータオブジェクトのプロパティが、表示されます。

O/R designer では、指定したファイル名「MD5」に対してアクセスオブジェクト「MD5DataConext」が生成され、D&Dしたテーブル DirEntry に対しては「DirEntry」、FileEntryに対しては「FileEntry」というデータオブジェクトが生成されます。プログラム的には、MD5DataConextが操作対象のデータベースを表現し、データオブジェクトはそれそれのテーブル、およびテーブル上のレコード、を表現します。

アクセスオブジェクトの使用

アクセスオブジェクト(データコンテキスト)を使うには、当該クラスのオブジェクトを new します。
dbconn = new MD5DataContext();
DB接続の詳細コードはデータコンテキスト内部に隠蔽されています。内部的には接続文字列等の普通にDB接続するときの情報が含まれているわけですが(プロパティで確認できます)、プログラム的にはそれらの一切がデータコンテキストで代表されてしまいます。いかにもコードがすっきりします。

DB上のテーブル内容へのアクセスは、上で生成されたアクセスオブジェクト経由で
dbconn.DirEntry          DirEntry テーブル内容へのアクセス 
  dbconn.FileEntry         FileEntryテーブル内容へのアクセス
でアクセス可能になります。

問い合わせでは上記のオブジェクトをそのままLINQ問い合わせの対象として使うことができます。
dbconn.DirEntry.Where((x) => (x.Name == path)
     dbconn.FileEntry.Where((x) => (x.DirID == de.DirUID)
データの挿入は、データオブジェクトをデータコンテキストの内部キャッシュに追加する形態になります。
dbconn.DirEntry.InsertOnSubmit(DirEntry newData)
     dbconn.FileEntry.InsertOnSubmit(FileEntry newData)
これらの挿入処理によって変更されたデータコンテキストのキャッシュは、明示的に更新を指示することによって(はじめて)データベースに反映されます。
dbconn.SubmitChanges()
なお、削除も同様に、
dbconn.DirEntry.DeleteOnSubmit(DirEntry de)           単一データ
     dbconn.DirEntry.DeleteAllOnSubmit(DirEntryの列挙)  複数データ   
     dbconn.SubmitChanges()
で行なえます。が、テーブル内の全データを削除するような場合には上の方法では時間がかかるので
dbconn.ExecuteCommand("TRUNCATE TABLE DirEntry")
     dbconn.ExecuteCommand("TRUNCATE TABLE FileEntry")
とした方が高速になります。まあ、SQL文が入ってしまうのでIDEでのチェックが効かなくなりますが。

参考:LINQ to SQL (MSDN)

DBへの登録プログラム

基本的には指定されたディレクトリについて、
  • 自分自身(ディレクトリ)をDirEntryテーブルに登録する
  • ディレクトリ配下のファイルをFileEntryテーブルに登録する
  • ディレクトリ配下のサブディレクトリを処理する
といったものになります。最後のサブディレクトリ処理は再帰呼出になります。
public static void make(string path)
        {
            // register specified directory
            string full = Path.GetFullPath(path);
            DirEntry de = addDir(full);
            Console.WriteLine("{0}\\", full);

            // register files in this directory
            Directory.SetCurrentDirectory(full);
            addFiles(de, Directory.GetFiles("."));

            // traverse to sub-directories
            string[] ds = Directory.GetDirectories(full);
            foreach (string dn in ds)
            {
                make(dn);
            }
        }

ディレクトリの登録

ディレクトリの登録(addDir)では存在チェックを行ない、存在していない場合に限って新規に登録します。既存であれば見つかったディレクトリエントリをそのまま返します。後続の処理でこのディレクトリ配下のファイルを一括処理するときに親ディレクトリ情報として既存、あるいは新規のディレクトリエントリを使いますので、関数値として返します。
public static DirEntry addDir(string path)
        {
            DirEntry de = getDir(path);
            if (de != null)
            {
                return de;
            }
            de = new DirEntry();
            de.DirUID = 0;
            de.Name = path;
            dbconn.DirEntry.InsertOnSubmit(de);
            dbconn.SubmitChanges();
            // de.DirUID was set on above action
            return de;
        }
新規に追加する場合にはデータオブジェクトを生成し、内容を設定してキャッシュに追加し、SubmitChangesでデータベースに反映させます。DirUID要素はテーブル側で追加時に自動設定されるユニークIDで、データオブジェクトを用意するときは常に0を設定していますが、SubmitChangesの実行によって変更されてDB側の自動設定値が反映されます。

ディレクトリの存在チェックはパス名をキーとしてテーブルを検索します。見つかった場合にはそのエントリを、見つからなかった場合には null を返します。
public static DirEntry getDir(string path)
        {
            var q = dbconn.DirEntry.Where((x) => x.Name == path);
            if (q.Count() == 0)
            {
                return null;
            }
            return q.First();
        }

ファイルの登録

あるディレクトリ下のファイルは、処理の高速化のためにまとめて処理します。一般にファイル参照を行なう時、フルパス名の解析(name break処理)はかなり時間が掛かります。このため特定ディレクトリに移動して、そこから短い相対パス名でアクセスするほうが負担が少なくなります。また、ファイルを登録する時にテーブルに対して既存チェックを行ないますが、ディレクトリ下のファイル一覧をメモリ上に持っておくと処理が高速化されます。この二つの高速化を行なうために、ファイル登録をディレクトリ単位で行なうようにしています。メモリ上のデータでも同じ形式で問い合わせができるLINQ、こういうシチュエーションでは嬉しいですね。

ところで、ここではコード側で重複チェックを行なうようにしていますが、これはテーブル側にユニーク制約を付けて重複した追加をエラーにする方法も考えられます。実際のところは、どちらの方式がいいのでしょうね。

ファイルの情報(FileEntry)には、ディレクトリへのリンク、ファイルのベース名、サイズ、日付、そしてファイルのハッシュ値を設定します。ディレクトリ、ファイル名は呼び出しパラメタで渡されます。ハッシュ値の計算は専門のクラスを用意します。残りはファイルのサイズと日付です。System.IO.Fileクラスでもファイルのサイズ、日付に参照できますが、このクラスを使った場合には、その都度ファイル情報にアクセスすることになり、今回のように複数のファイル情報に参照しようとする場合には不適切です。そこでSystem.IO.FileInfo クラスを使います。これですと一回のアクセスで複数のファイル情報をまとめて取得することができます。
static void addFiles(DirEntry de, string[] fs)
        {
            // for duplicate check, list files in the DB table
            FileEntry[] fl = dbconn.FileEntry.Where((x) => (x.DirID == de.DirUID)).ToArray();

            string fn;
            FileEntry fe;

            foreach (string name in fs)
            {
                fn = Path.GetFileName(name);
                var qf = fl.Where((x) => (x.Name == name));
                if (qf.Count() > 0)
                {
                    continue;
                }
                FileInfo fi = new System.IO.FileInfo(fn);
                fe = new FileEntry();
                fe.FileUID = 0;
                fe.DirID = de.DirUID;
                fe.Name = fn;
                fe.Size = fi.Length;
                fe.Date = (fi.LastWriteTimeUtc > fi.CreationTimeUtc ? fi.LastWriteTimeUtc : fi.CreationTimeUtc);
                fe.Ident = MD5sum.gethash(fn);
                dbconn.FileEntry.InsertOnSubmit(fe);
                dbconn.SubmitChanges();
            }
        }

重複チェック

これで、指定したディレクトリ下のすべてのファイルの情報+ハッシュ値をDBに取り込めるようになりました。当初の目的はファイルの重複チェックです。MD5ハッシュ値は同一内容のファイルでは同じ値になり、異なったファイルでは(ほぼ)別の値になります。無論確率的には異なった内容のファイルが同じハッシュ値を持つことも無いわけではないのですが、それは極めて低い確率で、ハッシュ値が同じであれば内容も同じであるとみなすことができます。

FileEntryテーブルで同じハッシュ値を持ったレコード(ただしファイル自体は別)を見つけるには、SQLでは以下のような問い合わせになります(ダイアグラムクエリーデザイナで生成)。
SELECT DISTINCT DirEntry.Name AS Expr1, FileEntry.Name, FileEntry.Size, 
                                      FileEntry.Date, FileEntry.Ident
        FROM  FileEntry INNER JOIN
                    DirEntry ON FileEntry.DirID = DirEntry.DirUID INNER JOIN
                    FileEntry AS FileEntry_1 ON FileEntry.Ident = FileEntry_1.Ident 
                                                             AND FileEntry.FileUID <> FileEntry_1.FileUID
        ORDER BY       FileEntry.Ident, Expr1, FileEntry.Name
で生成されたDBに対してこれを実行してみると、テストでスキャンした3000ファイル中、198の重複が見つかりました。無駄なファイルが一杯あることが判ります。

以上でDB操作をひと通りカバーしましたので、中断していた元の勉強に戻ることとします。

0 件のコメント:

コメントを投稿